Merge branch 'main' into docs/audit-urls
@ -36,6 +36,7 @@ const initialState = {
|
||||
secret: '',
|
||||
acrValues: '',
|
||||
idTokenSigningAlgorithm: 'RS256',
|
||||
enablePkce: false,
|
||||
};
|
||||
|
||||
type State = typeof initialState & {
|
||||
@ -47,6 +48,7 @@ export const OidcAuth = () => {
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { oidcConfiguredThroughEnv } = uiConfig;
|
||||
const oidcPkceSupport = Boolean(uiConfig.flags?.oidcPkceSupport);
|
||||
const [data, setData] = useState<State>(initialState);
|
||||
const { config } = useAuthSettings('oidc');
|
||||
const { updateSettings, errors, loading } = useAuthSettingsApi('oidc');
|
||||
@ -253,6 +255,44 @@ export const OidcAuth = () => {
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<ConditionallyRender
|
||||
condition={oidcPkceSupport}
|
||||
show={
|
||||
<Grid container spacing={3} mb={2}>
|
||||
<Grid item md={5}>
|
||||
<strong>Enable PKCE</strong>
|
||||
<p>
|
||||
Require Proof Key for Code Exchange (PKCE)
|
||||
to add an extra layer of security for the
|
||||
authorization code flow.
|
||||
</p>
|
||||
</Grid>
|
||||
<Grid item md={6} style={{ padding: '20px' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
onChange={(event) =>
|
||||
setValue(
|
||||
'enablePkce',
|
||||
event.target.checked,
|
||||
)
|
||||
}
|
||||
name='enablePkce'
|
||||
checked={Boolean(data.enablePkce)}
|
||||
disabled={
|
||||
!data.enabled ||
|
||||
oidcConfiguredThroughEnv
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
data.enablePkce ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
/>
|
||||
<Grid container spacing={3} mb={2}>
|
||||
<Grid item md={5}>
|
||||
<strong>ACR Values</strong>
|
||||
|
||||
@ -82,6 +82,19 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
padding: theme.spacing(0, 1),
|
||||
}));
|
||||
|
||||
const getHosting = ({
|
||||
hosting,
|
||||
}: ConnectedEdge): 'Cloud' | 'Self-hosted' | 'Unknown' => {
|
||||
switch (hosting) {
|
||||
case 'hosted':
|
||||
return 'Cloud';
|
||||
case 'enterprise-self-hosted':
|
||||
return 'Self-hosted';
|
||||
default:
|
||||
return hosting ? `Unknown: ${hosting}` : 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatus = ({
|
||||
reportedAt,
|
||||
}: ConnectedEdge): InstanceConnectionStatus => {
|
||||
@ -178,6 +191,10 @@ export const NetworkConnectedEdgeInstance = ({
|
||||
<strong>Region</strong>
|
||||
<span>{instance.region || 'Unknown'}</span>
|
||||
</StyledDetailRow>
|
||||
<StyledDetailRow>
|
||||
<strong>Hosting</strong>
|
||||
<span>{getHosting(instance)}</span>
|
||||
</StyledDetailRow>
|
||||
<StyledDetailRow>
|
||||
<strong>Version</strong>
|
||||
<span>{instance.edgeVersion}</span>
|
||||
|
||||
@ -110,8 +110,6 @@ const MilestoneListRendererCore = ({
|
||||
readonly={readonly}
|
||||
milestone={milestone}
|
||||
automationSection={automationSection}
|
||||
allMilestones={plan.milestones}
|
||||
activeMilestoneId={plan.activeMilestoneId}
|
||||
/>
|
||||
{isNotLastMilestone && <StyledConnection />}
|
||||
</div>
|
||||
|
||||
@ -106,12 +106,7 @@ const StartMilestone: FC<{
|
||||
</div>
|
||||
</ChangeItemWrapper>
|
||||
<TabPanel>
|
||||
<ReleasePlanMilestone
|
||||
readonly
|
||||
milestone={newMilestone}
|
||||
allMilestones={releasePlan.milestones}
|
||||
activeMilestoneId={releasePlan.activeMilestoneId}
|
||||
/>
|
||||
<ReleasePlanMilestone readonly milestone={newMilestone} />
|
||||
</TabPanel>
|
||||
<TabPanel variant='diff'>
|
||||
<EventDiff
|
||||
|
||||
@ -15,33 +15,10 @@ const StyledInputGroup = styled('div')(({ theme }) => ({
|
||||
|
||||
const StyledTextField = styled(TextField)(({ theme }) => ({
|
||||
width: '60px',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: theme.spacing(0.5),
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
'& input': {
|
||||
textAlign: 'center',
|
||||
padding: theme.spacing(0.75, 1),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledSelect = styled(Select)(({ theme }) => ({
|
||||
width: '100px',
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderRadius: theme.spacing(0.5),
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
'& .MuiSelect-select': {
|
||||
padding: theme.spacing(0.75, 1.25),
|
||||
},
|
||||
}));
|
||||
|
||||
interface IMilestoneProgressionTimeInputProps {
|
||||
@ -75,8 +52,7 @@ export const MilestoneProgressionTimeInput = ({
|
||||
return (
|
||||
<StyledInputGroup>
|
||||
<StyledTextField
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
type='number'
|
||||
value={timeValue}
|
||||
onChange={onTimeValueChange}
|
||||
onPaste={handleNumericPaste}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import { Alert, styled } from '@mui/material';
|
||||
import { Alert, styled, Link } from '@mui/material';
|
||||
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';
|
||||
@ -84,18 +85,17 @@ const StyledBody = styled('div', {
|
||||
...(safeguards && {
|
||||
border: `1px dashed ${theme.palette.neutral.border}`,
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
padding: theme.spacing(0.5, 0),
|
||||
}),
|
||||
}));
|
||||
|
||||
const StyledAddSafeguard = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
borderBottom: `1px dashed ${theme.palette.neutral.border}`,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
padding: theme.spacing(0.25, 0.25),
|
||||
}));
|
||||
|
||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
margin: theme.spacing(1, 2),
|
||||
margin: theme.spacing(1, 0),
|
||||
}));
|
||||
|
||||
const StyledMilestones = styled('div', {
|
||||
@ -106,6 +106,14 @@ const StyledMilestones = styled('div', {
|
||||
}),
|
||||
}));
|
||||
|
||||
const StyledResumeMilestoneProgressions = styled(Link)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}));
|
||||
|
||||
interface IReleasePlanProps {
|
||||
plan: IReleasePlan;
|
||||
environmentIsDisabled?: boolean;
|
||||
@ -136,8 +144,11 @@ export const ReleasePlan = ({
|
||||
);
|
||||
const { removeReleasePlanFromFeature, startReleasePlanMilestone } =
|
||||
useReleasePlansApi();
|
||||
const { deleteMilestoneProgression, loading: milestoneProgressionLoading } =
|
||||
useMilestoneProgressionsApi();
|
||||
const {
|
||||
deleteMilestoneProgression,
|
||||
resumeMilestoneProgressions,
|
||||
loading: milestoneProgressionLoading,
|
||||
} = useMilestoneProgressionsApi();
|
||||
const {
|
||||
createOrUpdateSafeguard,
|
||||
deleteSafeguard,
|
||||
@ -371,12 +382,12 @@ export const ReleasePlan = ({
|
||||
return;
|
||||
|
||||
try {
|
||||
await deleteMilestoneProgression(
|
||||
await deleteMilestoneProgression({
|
||||
projectId,
|
||||
environment,
|
||||
featureName,
|
||||
milestoneToDeleteProgression.id,
|
||||
);
|
||||
sourceMilestoneId: milestoneToDeleteProgression.id,
|
||||
});
|
||||
await refetch();
|
||||
setMilestoneToDeleteProgression(null);
|
||||
setToastData({
|
||||
@ -389,6 +400,24 @@ export const ReleasePlan = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onResumeMilestoneProgressions = async () => {
|
||||
try {
|
||||
await resumeMilestoneProgressions({
|
||||
projectId,
|
||||
environment,
|
||||
featureName,
|
||||
planId: id,
|
||||
});
|
||||
setToastData({
|
||||
type: 'success',
|
||||
text: 'Automation resumed successfully',
|
||||
});
|
||||
refetch();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const activeIndex = milestones.findIndex(
|
||||
(milestone) => milestone.id === activeMilestoneId,
|
||||
);
|
||||
@ -475,13 +504,25 @@ export const ReleasePlan = ({
|
||||
</PermissionIconButton>
|
||||
)}
|
||||
</StyledHeader>
|
||||
{releasePlanAutomationsPaused ? (
|
||||
<StyledAlert
|
||||
severity='error'
|
||||
action={
|
||||
<StyledResumeMilestoneProgressions
|
||||
variant='body2'
|
||||
onClick={onResumeMilestoneProgressions}
|
||||
>
|
||||
<PlayCircle />
|
||||
Resume automation
|
||||
</StyledResumeMilestoneProgressions>
|
||||
}
|
||||
>
|
||||
<b>Automation paused by safeguard.</b> Existing users on
|
||||
this release plan can still access the feature.
|
||||
</StyledAlert>
|
||||
) : null}
|
||||
|
||||
<StyledBody safeguards={safeguardsEnabled}>
|
||||
{releasePlanAutomationsPaused ? (
|
||||
<StyledAlert severity='error'>
|
||||
<b>Automation paused by safeguard.</b> Existing users on
|
||||
this release plan can still access the feature.
|
||||
</StyledAlert>
|
||||
) : null}
|
||||
{safeguardsEnabled ? (
|
||||
<StyledAddSafeguard>
|
||||
{safeguards.length > 0 ? (
|
||||
|
||||
@ -104,8 +104,7 @@ interface IReleasePlanMilestoneProps {
|
||||
onStartMilestone?: (milestone: IReleasePlanMilestone) => void;
|
||||
readonly?: boolean;
|
||||
automationSection?: React.ReactNode;
|
||||
allMilestones: IReleasePlanMilestone[];
|
||||
activeMilestoneId?: string;
|
||||
previousMilestoneStatus?: MilestoneStatus;
|
||||
}
|
||||
|
||||
export const ReleasePlanMilestone = ({
|
||||
@ -114,11 +113,12 @@ export const ReleasePlanMilestone = ({
|
||||
onStartMilestone,
|
||||
readonly,
|
||||
automationSection,
|
||||
allMilestones,
|
||||
activeMilestoneId,
|
||||
previousMilestoneStatus,
|
||||
}: IReleasePlanMilestoneProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasAutomation = Boolean(automationSection);
|
||||
const isPreviousMilestonePaused =
|
||||
previousMilestoneStatus?.type === 'paused';
|
||||
|
||||
if (!milestone.strategies.length) {
|
||||
return (
|
||||
@ -136,11 +136,12 @@ export const ReleasePlanMilestone = ({
|
||||
(status.type === 'active' &&
|
||||
milestone.startedAt) ? (
|
||||
<StyledStatusRow>
|
||||
{!readonly && (
|
||||
<MilestoneNextStartTime
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
{!readonly &&
|
||||
!isPreviousMilestonePaused && (
|
||||
<MilestoneNextStartTime
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
{!readonly && onStartMilestone && (
|
||||
<ReleasePlanMilestoneStatus
|
||||
status={status}
|
||||
@ -187,7 +188,7 @@ export const ReleasePlanMilestone = ({
|
||||
{(!readonly && onStartMilestone) ||
|
||||
(status.type === 'active' && milestone.startedAt) ? (
|
||||
<StyledStatusRow>
|
||||
{!readonly && (
|
||||
{!readonly && !isPreviousMilestonePaused && (
|
||||
<MilestoneNextStartTime status={status} />
|
||||
)}
|
||||
{!readonly && onStartMilestone && (
|
||||
|
||||
@ -54,13 +54,12 @@ export const MilestoneAutomation = ({
|
||||
pendingProgressionChange?.action === 'changeMilestoneProgression';
|
||||
const hasPendingDelete =
|
||||
pendingProgressionChange?.action === 'deleteMilestoneProgression';
|
||||
const isPaused = Boolean(milestone.pausedAt);
|
||||
|
||||
const badge = hasPendingDelete ? (
|
||||
<Badge color='error'>Deleted in draft</Badge>
|
||||
) : hasPendingChange ? (
|
||||
<Badge color='warning'>Modified in draft</Badge>
|
||||
) : isPaused ? (
|
||||
) : status?.type === 'paused' ? (
|
||||
<Badge color='error' icon={<WarningAmber fontSize='small' />}>
|
||||
Paused
|
||||
</Badge>
|
||||
|
||||
@ -122,13 +122,13 @@ export const ReleasePlanMilestoneItem = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await changeMilestoneProgression(
|
||||
await changeMilestoneProgression({
|
||||
projectId,
|
||||
environment,
|
||||
featureName,
|
||||
milestone.id,
|
||||
payload,
|
||||
);
|
||||
sourceMilestoneId: milestone.id,
|
||||
body: payload,
|
||||
});
|
||||
setToastData({
|
||||
type: 'success',
|
||||
text: 'Automation configured successfully',
|
||||
@ -151,6 +151,18 @@ export const ReleasePlanMilestoneItem = ({
|
||||
milestones,
|
||||
);
|
||||
|
||||
const previousMilestone = index > 0 ? milestones[index - 1] : null;
|
||||
const previousMilestoneStatus = previousMilestone
|
||||
? calculateMilestoneStatus(
|
||||
previousMilestone,
|
||||
activeMilestoneId,
|
||||
index - 1,
|
||||
activeIndex,
|
||||
environmentIsDisabled,
|
||||
milestones,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const { pendingProgressionChange, effectiveTransitionCondition } =
|
||||
getPendingProgressionData(milestone, getPendingProgressionChange);
|
||||
|
||||
@ -183,8 +195,7 @@ export const ReleasePlanMilestoneItem = ({
|
||||
status={status}
|
||||
onStartMilestone={onStartMilestone}
|
||||
automationSection={automationSection}
|
||||
allMilestones={milestones}
|
||||
activeMilestoneId={activeMilestoneId}
|
||||
previousMilestoneStatus={previousMilestoneStatus}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={isNotLastMilestone}
|
||||
|
||||
@ -10,6 +10,10 @@ export const calculateMilestoneStatus = (
|
||||
environmentIsDisabled: boolean | undefined,
|
||||
allMilestones: IReleasePlanMilestone[],
|
||||
): MilestoneStatus => {
|
||||
if (milestone.pausedAt) {
|
||||
return { type: 'paused' };
|
||||
}
|
||||
|
||||
if (milestone.id === activeMilestoneId) {
|
||||
return environmentIsDisabled ? { type: 'paused' } : { type: 'active' };
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import type { MetricQuerySchemaAggregationMode } from 'openapi/models/metricQuer
|
||||
import type { CreateSafeguardSchemaOperator } from 'openapi/models/createSafeguardSchemaOperator';
|
||||
import {
|
||||
createStyledIcon,
|
||||
type FormMode,
|
||||
StyledButtonGroup,
|
||||
StyledFormContainer,
|
||||
StyledLabel,
|
||||
@ -32,11 +33,9 @@ interface ISafeguardFormProps {
|
||||
safeguard?: ISafeguard;
|
||||
}
|
||||
|
||||
type FormMode = 'create' | 'edit' | 'display';
|
||||
|
||||
const getInitialValues = (safeguard?: ISafeguard) => ({
|
||||
metricName: safeguard?.impactMetric.metricName || '',
|
||||
appName: safeguard?.impactMetric.labelSelectors.appName[0] || '*',
|
||||
appName: safeguard?.impactMetric.labelSelectors.appName?.[0] || '*',
|
||||
aggregationMode: (safeguard?.impactMetric.aggregationMode ||
|
||||
'rps') as MetricQuerySchemaAggregationMode,
|
||||
operator: (safeguard?.triggerCondition.operator ||
|
||||
@ -218,7 +217,7 @@ export const SafeguardForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledFormContainer onSubmit={handleSubmit}>
|
||||
<StyledFormContainer onSubmit={handleSubmit} mode={mode}>
|
||||
<StyledTopRow sx={{ mb: 1 }}>
|
||||
<StyledIcon />
|
||||
<StyledLabel>Pause automation when</StyledLabel>
|
||||
@ -244,7 +243,6 @@ export const SafeguardForm = ({
|
||||
<StyledLabel>filtered by</StyledLabel>
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<StyledSelect
|
||||
sx={{ minWidth: 200 }}
|
||||
value={appName}
|
||||
onChange={(e) =>
|
||||
handleApplicationChange(String(e.target.value))
|
||||
@ -287,12 +285,15 @@ export const SafeguardForm = ({
|
||||
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<TextField
|
||||
sx={{ minWidth: 120 }}
|
||||
type='number'
|
||||
inputProps={{
|
||||
step: 0.1,
|
||||
}}
|
||||
value={threshold}
|
||||
onChange={(e) =>
|
||||
handleThresholdChange(Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
handleThresholdChange(Number(value));
|
||||
}}
|
||||
placeholder='Value'
|
||||
variant='outlined'
|
||||
size='small'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { isPast, addMinutes } from 'date-fns';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { addMinutes, isPast } from 'date-fns';
|
||||
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
|
||||
import { formatDateYMDHM } from 'utils/formatDate.ts';
|
||||
|
||||
@ -112,10 +112,7 @@ export const useMilestoneProgressionForm = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const inputValue = event.target.value;
|
||||
if (inputValue === '' || /^\d+$/.test(inputValue)) {
|
||||
const value = inputValue === '' ? 0 : Number.parseInt(inputValue);
|
||||
setTimeValue(value);
|
||||
}
|
||||
setTimeValue(Number(inputValue));
|
||||
};
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
import { styled, Select, MenuItem } from '@mui/material';
|
||||
|
||||
export const StyledFormContainer = styled('form')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1.5),
|
||||
padding: theme.spacing(1.5, 2),
|
||||
backgroundColor: theme.palette.background.elevation1,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
width: '100%',
|
||||
borderRadius: `${theme.shape.borderRadiusLarge}px`,
|
||||
position: 'relative',
|
||||
}));
|
||||
export type FormMode = 'create' | 'edit' | 'display';
|
||||
|
||||
interface StyledFormContainerProps {
|
||||
mode?: FormMode;
|
||||
}
|
||||
|
||||
export const StyledFormContainer = styled('form')<StyledFormContainerProps>(
|
||||
({ theme, mode }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1.5),
|
||||
backgroundColor: theme.palette.background.elevation1,
|
||||
padding:
|
||||
mode === 'display' ? theme.spacing(1, 1.5) : theme.spacing(1.5, 2),
|
||||
border:
|
||||
mode === 'display' ? 'none' : `1px solid ${theme.palette.divider}`,
|
||||
transition: theme.transitions.create(['padding'], {
|
||||
duration: theme.transitions.duration.short,
|
||||
}),
|
||||
width: '100%',
|
||||
borderRadius: `${theme.shape.borderRadiusMedium}px`,
|
||||
position: 'relative',
|
||||
}),
|
||||
);
|
||||
|
||||
export const StyledTopRow = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
|
||||
@ -15,7 +15,7 @@ export const ModeSelector: FC<ModeSelectorProps> = ({
|
||||
}) => {
|
||||
if (metricType === 'unknown') return null;
|
||||
return (
|
||||
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<InputLabel id='mode-select-label'>Mode</InputLabel>
|
||||
<Select
|
||||
labelId='mode-select-label'
|
||||
|
||||
@ -9,7 +9,7 @@ export type RangeSelectorProps = {
|
||||
};
|
||||
|
||||
export const RangeSelector: FC<RangeSelectorProps> = ({ value, onChange }) => (
|
||||
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<InputLabel id='range-select-label'>Time</InputLabel>
|
||||
<Select
|
||||
labelId='range-select-label'
|
||||
|
||||
@ -104,22 +104,24 @@ export const ChartItem: FC<ChartItemProps> = ({
|
||||
{getConfigDescription(config)}
|
||||
</Typography>
|
||||
</StyledChartTitle>
|
||||
<StyledChartActions>
|
||||
<PermissionIconButton
|
||||
onClick={() => onEdit(config)}
|
||||
permission={permission}
|
||||
projectId={projectId}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
onClick={() => onDelete(config.id)}
|
||||
permission={permission}
|
||||
projectId={projectId}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
</StyledChartActions>
|
||||
{config.mode !== 'read' && (
|
||||
<StyledChartActions>
|
||||
<PermissionIconButton
|
||||
onClick={() => onEdit(config)}
|
||||
permission={permission}
|
||||
projectId={projectId}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
onClick={() => onDelete(config.id)}
|
||||
permission={permission}
|
||||
projectId={projectId}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
</StyledChartActions>
|
||||
)}
|
||||
</StyledHeader>
|
||||
|
||||
<StyledChartContent>
|
||||
|
||||
@ -20,6 +20,7 @@ export type AggregationMode =
|
||||
export type DisplayChartConfig = ChartConfig & {
|
||||
type: 'counter' | 'gauge' | 'histogram' | 'unknown';
|
||||
displayName: string; // e.g. my_metric with unleash_counter stripped
|
||||
mode?: 'read' | 'write';
|
||||
};
|
||||
|
||||
export type LayoutItem = {
|
||||
|
||||
@ -6,13 +6,19 @@ export const useMilestoneProgressionsApi = () => {
|
||||
propagateErrors: true,
|
||||
});
|
||||
|
||||
const changeMilestoneProgression = async (
|
||||
projectId: string,
|
||||
environment: string,
|
||||
featureName: string,
|
||||
sourceMilestoneId: string,
|
||||
body: ChangeMilestoneProgressionSchema,
|
||||
): Promise<void> => {
|
||||
const changeMilestoneProgression = async ({
|
||||
projectId,
|
||||
environment,
|
||||
featureName,
|
||||
sourceMilestoneId,
|
||||
body,
|
||||
}: {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
featureName: string;
|
||||
sourceMilestoneId: string;
|
||||
body: ChangeMilestoneProgressionSchema;
|
||||
}): Promise<void> => {
|
||||
const requestId = 'changeMilestoneProgression';
|
||||
const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/progressions/${sourceMilestoneId}`;
|
||||
const req = createRequest(
|
||||
@ -27,12 +33,17 @@ export const useMilestoneProgressionsApi = () => {
|
||||
await makeRequest(req.caller, req.id);
|
||||
};
|
||||
|
||||
const deleteMilestoneProgression = async (
|
||||
projectId: string,
|
||||
environment: string,
|
||||
featureName: string,
|
||||
sourceMilestoneId: string,
|
||||
): Promise<void> => {
|
||||
const deleteMilestoneProgression = async ({
|
||||
projectId,
|
||||
environment,
|
||||
featureName,
|
||||
sourceMilestoneId,
|
||||
}: {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
featureName: string;
|
||||
sourceMilestoneId: string;
|
||||
}): Promise<void> => {
|
||||
const requestId = 'deleteMilestoneProgression';
|
||||
const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/progressions/${sourceMilestoneId}`;
|
||||
const req = createRequest(
|
||||
@ -46,9 +57,34 @@ export const useMilestoneProgressionsApi = () => {
|
||||
await makeRequest(req.caller, req.id);
|
||||
};
|
||||
|
||||
const resumeMilestoneProgressions = async ({
|
||||
projectId,
|
||||
environment,
|
||||
featureName,
|
||||
planId,
|
||||
}: {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
featureName: string;
|
||||
planId: string;
|
||||
}): Promise<void> => {
|
||||
const requestId = 'resumeProgressions';
|
||||
const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/progressions/${planId}/resume`;
|
||||
const req = createRequest(
|
||||
path,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
requestId,
|
||||
);
|
||||
|
||||
await makeRequest(req.caller, req.id);
|
||||
};
|
||||
|
||||
return {
|
||||
changeMilestoneProgression,
|
||||
deleteMilestoneProgression,
|
||||
resumeMilestoneProgressions,
|
||||
errors,
|
||||
loading,
|
||||
};
|
||||
|
||||
@ -5,6 +5,7 @@ export type ConnectedEdge = {
|
||||
edgeVersion: string;
|
||||
instanceId: string;
|
||||
region: string | null;
|
||||
hosting?: 'hosted' | 'enterprise-self-hosted';
|
||||
reportedAt: string;
|
||||
started: string;
|
||||
connectedVia?: string;
|
||||
|
||||
@ -90,6 +90,7 @@ export type UiFlags = {
|
||||
milestoneProgression?: boolean;
|
||||
featureReleasePlans?: boolean;
|
||||
safeguards?: boolean;
|
||||
oidcPkceSupport?: boolean;
|
||||
extendedUsageMetrics?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@ -150,6 +150,8 @@ export const EventSchemaType = {
|
||||
'banner-created': 'banner-created',
|
||||
'banner-updated': 'banner-updated',
|
||||
'banner-deleted': 'banner-deleted',
|
||||
'safeguard-changed': 'safeguard-changed',
|
||||
'safeguard-deleted': 'safeguard-deleted',
|
||||
'project-environment-added': 'project-environment-added',
|
||||
'project-environment-removed': 'project-environment-removed',
|
||||
'default-strategy-updated': 'default-strategy-updated',
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
import type { ImpactMetricsConfigSchemaAggregationMode } from './impactMetricsConfigSchemaAggregationMode.js';
|
||||
import type { ImpactMetricsConfigSchemaLabelSelectors } from './impactMetricsConfigSchemaLabelSelectors.js';
|
||||
import type { ImpactMetricsConfigSchemaMode } from './impactMetricsConfigSchemaMode.js';
|
||||
import type { ImpactMetricsConfigSchemaTimeRange } from './impactMetricsConfigSchemaTimeRange.js';
|
||||
import type { ImpactMetricsConfigSchemaType } from './impactMetricsConfigSchemaType.js';
|
||||
import type { ImpactMetricsConfigSchemaYAxisMin } from './impactMetricsConfigSchemaYAxisMin.js';
|
||||
@ -23,6 +24,8 @@ export interface ImpactMetricsConfigSchema {
|
||||
labelSelectors: ImpactMetricsConfigSchemaLabelSelectors;
|
||||
/** The Prometheus metric series to query. It includes both unleash prefix and metric type and display name */
|
||||
metricName: string;
|
||||
/** The access mode for this impact metric configuration: "read" when referenced by a safeguard, "write" otherwise. */
|
||||
mode?: ImpactMetricsConfigSchemaMode;
|
||||
/** The time range for the metric data. */
|
||||
timeRange: ImpactMetricsConfigSchemaTimeRange;
|
||||
/**
|
||||
|
||||
17
frontend/src/openapi/models/impactMetricsConfigSchemaMode.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generated by Orval
|
||||
* Do not edit manually.
|
||||
* See `gen:api` script in package.json
|
||||
*/
|
||||
|
||||
/**
|
||||
* The access mode for this impact metric configuration: "read" when referenced by a safeguard, "write" otherwise.
|
||||
*/
|
||||
export type ImpactMetricsConfigSchemaMode =
|
||||
(typeof ImpactMetricsConfigSchemaMode)[keyof typeof ImpactMetricsConfigSchemaMode];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const ImpactMetricsConfigSchemaMode = {
|
||||
read: 'read',
|
||||
write: 'write',
|
||||
} as const;
|
||||
@ -949,6 +949,7 @@ export * from './impactMetricsConfigListSchema.js';
|
||||
export * from './impactMetricsConfigSchema.js';
|
||||
export * from './impactMetricsConfigSchemaAggregationMode.js';
|
||||
export * from './impactMetricsConfigSchemaLabelSelectors.js';
|
||||
export * from './impactMetricsConfigSchemaMode.js';
|
||||
export * from './impactMetricsConfigSchemaTimeRange.js';
|
||||
export * from './impactMetricsConfigSchemaType.js';
|
||||
export * from './impactMetricsConfigSchemaYAxisMin.js';
|
||||
@ -1283,6 +1284,9 @@ export * from './resetUserPassword401.js';
|
||||
export * from './resetUserPassword403.js';
|
||||
export * from './resetUserPassword404.js';
|
||||
export * from './resourceLimitsSchema.js';
|
||||
export * from './resumeMilestoneProgressions401.js';
|
||||
export * from './resumeMilestoneProgressions403.js';
|
||||
export * from './resumeMilestoneProgressions404.js';
|
||||
export * from './reviveFeature400.js';
|
||||
export * from './reviveFeature401.js';
|
||||
export * from './reviveFeature403.js';
|
||||
|
||||
@ -32,6 +32,8 @@ export interface OidcSettingsResponseSchema {
|
||||
enabled?: boolean;
|
||||
/** Should we enable group syncing. Refer to the documentation [Group syncing](https://docs.getunleash.io/how-to/how-to-set-up-group-sso-sync) */
|
||||
enableGroupSyncing?: boolean;
|
||||
/** Enable PKCE (Proof Key for Code Exchange) for enhanced security. Recommended for public clients and provides additional protection against authorization code interception attacks. */
|
||||
enablePkce?: boolean;
|
||||
/** Support Single sign out when user clicks logout in Unleash. If `true` user is signed out of all OpenID Connect sessions against the clientId they may have active */
|
||||
enableSingleSignOut?: boolean;
|
||||
/** Specifies the path in the OIDC token response to read which groups the user belongs to from. */
|
||||
|
||||
@ -29,6 +29,8 @@ export type OidcSettingsSchemaOneOf = {
|
||||
enabled: boolean;
|
||||
/** Should we enable group syncing. Refer to the documentation [Group syncing](https://docs.getunleash.io/how-to/how-to-set-up-group-sso-sync) */
|
||||
enableGroupSyncing?: boolean;
|
||||
/** Enable PKCE (Proof Key for Code Exchange) for enhanced security. Recommended for public clients and provides additional protection against authorization code interception attacks. */
|
||||
enablePkce?: boolean;
|
||||
/** Support Single sign out when user clicks logout in Unleash. If `true` user is signed out of all OpenID Connect sessions against the clientId they may have active */
|
||||
enableSingleSignOut?: boolean;
|
||||
/** Specifies the path in the OIDC token response to read which groups the user belongs to from. */
|
||||
|
||||
@ -29,6 +29,8 @@ export type OidcSettingsSchemaOneOfFour = {
|
||||
enabled?: boolean;
|
||||
/** Should we enable group syncing. Refer to the documentation [Group syncing](https://docs.getunleash.io/how-to/how-to-set-up-group-sso-sync) */
|
||||
enableGroupSyncing?: boolean;
|
||||
/** Enable PKCE (Proof Key for Code Exchange) for enhanced security. Recommended for public clients and provides additional protection against authorization code interception attacks. */
|
||||
enablePkce?: boolean;
|
||||
/** Support Single sign out when user clicks logout in Unleash. If `true` user is signed out of all OpenID Connect sessions against the clientId they may have active */
|
||||
enableSingleSignOut?: boolean;
|
||||
/** Specifies the path in the OIDC token response to read which groups the user belongs to from. */
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by Orval
|
||||
* Do not edit manually.
|
||||
* See `gen:api` script in package.json
|
||||
*/
|
||||
|
||||
export type ResumeMilestoneProgressions401 = {
|
||||
/** The ID of the error instance */
|
||||
id?: string;
|
||||
/** A description of what went wrong. */
|
||||
message?: string;
|
||||
/** The name of the error kind */
|
||||
name?: string;
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by Orval
|
||||
* Do not edit manually.
|
||||
* See `gen:api` script in package.json
|
||||
*/
|
||||
|
||||
export type ResumeMilestoneProgressions403 = {
|
||||
/** The ID of the error instance */
|
||||
id?: string;
|
||||
/** A description of what went wrong. */
|
||||
message?: string;
|
||||
/** The name of the error kind */
|
||||
name?: string;
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by Orval
|
||||
* Do not edit manually.
|
||||
* See `gen:api` script in package.json
|
||||
*/
|
||||
|
||||
export type ResumeMilestoneProgressions404 = {
|
||||
/** The ID of the error instance */
|
||||
id?: string;
|
||||
/** A description of what went wrong. */
|
||||
message?: string;
|
||||
/** The name of the error kind */
|
||||
name?: string;
|
||||
};
|
||||
@ -180,6 +180,9 @@ export const BANNER_CREATED = 'banner-created' as const;
|
||||
export const BANNER_UPDATED = 'banner-updated' as const;
|
||||
export const BANNER_DELETED = 'banner-deleted' as const;
|
||||
|
||||
export const SAFEGUARD_CHANGED = 'safeguard-changed' as const;
|
||||
export const SAFEGUARD_DELETED = 'safeguard-deleted' as const;
|
||||
|
||||
export const SIGNAL_ENDPOINT_CREATED = 'signal-endpoint-created' as const;
|
||||
export const SIGNAL_ENDPOINT_UPDATED = 'signal-endpoint-updated' as const;
|
||||
export const SIGNAL_ENDPOINT_DELETED = 'signal-endpoint-deleted' as const;
|
||||
@ -363,6 +366,8 @@ export const IEventTypes = [
|
||||
BANNER_CREATED,
|
||||
BANNER_UPDATED,
|
||||
BANNER_DELETED,
|
||||
SAFEGUARD_CHANGED,
|
||||
SAFEGUARD_DELETED,
|
||||
PROJECT_ENVIRONMENT_ADDED,
|
||||
PROJECT_ENVIRONMENT_REMOVED,
|
||||
DEFAULT_STRATEGY_UPDATED,
|
||||
|
||||
@ -10,7 +10,9 @@ import FakeEventStore from '../../../test/fixtures/fake-event-store.js';
|
||||
import { FakeAccountStore } from '../../../test/fixtures/fake-account-store.js';
|
||||
import FakeRoleStore from '../../../test/fixtures/fake-role-store.js';
|
||||
import FakeEnvironmentStore from '../project-environments/fake-environment-store.js';
|
||||
import FakeAccessStore from '../../../test/fixtures/fake-access-store.js';
|
||||
import FakeAccessStore, {
|
||||
type FakeAccessStoreConfig,
|
||||
} from '../../../test/fixtures/fake-access-store.js';
|
||||
import type {
|
||||
IAccessStore,
|
||||
IEventStore,
|
||||
@ -45,8 +47,13 @@ export const createAccessService = (
|
||||
);
|
||||
};
|
||||
|
||||
export type FakeAccessServiceConfig = {
|
||||
accessStoreConfig?: FakeAccessStoreConfig;
|
||||
};
|
||||
|
||||
export const createFakeAccessService = (
|
||||
config: IUnleashConfig,
|
||||
{ accessStoreConfig }: FakeAccessServiceConfig = {},
|
||||
): {
|
||||
accessService: AccessService;
|
||||
eventStore: IEventStore;
|
||||
@ -59,7 +66,7 @@ export const createFakeAccessService = (
|
||||
const accountStore = new FakeAccountStore();
|
||||
const roleStore = new FakeRoleStore();
|
||||
const environmentStore = new FakeEnvironmentStore();
|
||||
const accessStore = new FakeAccessStore(roleStore);
|
||||
const accessStore = new FakeAccessStore(roleStore, accessStoreConfig);
|
||||
const eventService = createFakeEventsService(config, { eventStore });
|
||||
const groupService = new GroupService(
|
||||
{ groupStore, accountStore },
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import NameExistsError from '../error/name-exists-error.js';
|
||||
import getLogger from '../../test/fixtures/no-logger.js';
|
||||
import { createFakeAccessService } from '../features/access/createAccessService.js';
|
||||
import {
|
||||
createFakeAccessService,
|
||||
type FakeAccessServiceConfig,
|
||||
} from '../features/access/createAccessService.js';
|
||||
import {
|
||||
AccessService,
|
||||
type IRoleCreation,
|
||||
@ -29,13 +32,15 @@ import { createFakeAccessReadModel } from '../features/access/createAccessReadMo
|
||||
import { ROLE_CREATED } from '../events/index.js';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
function getSetup() {
|
||||
function getSetup(accessServiceConfig?: FakeAccessServiceConfig) {
|
||||
const config = createTestConfig({
|
||||
getLogger,
|
||||
});
|
||||
|
||||
const { accessService, eventStore, accessStore } =
|
||||
createFakeAccessService(config);
|
||||
const { accessService, eventStore, accessStore } = createFakeAccessService(
|
||||
config,
|
||||
accessServiceConfig,
|
||||
);
|
||||
|
||||
return {
|
||||
accessService,
|
||||
@ -213,7 +218,24 @@ test('should be able to validate and cleanup with additional properties', async
|
||||
});
|
||||
|
||||
test('user with custom root role should get a user root role', async () => {
|
||||
const { accessService, eventStore } = getSetup();
|
||||
const availablePermissions = [
|
||||
{
|
||||
id: 1,
|
||||
environment: 'development',
|
||||
name: 'fake',
|
||||
displayName: 'fake',
|
||||
type: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'root-fake-permission',
|
||||
displayName: '',
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
const { accessService, eventStore } = getSetup({
|
||||
accessStoreConfig: { availablePermissions },
|
||||
});
|
||||
const createRoleInput: IRoleCreation = {
|
||||
name: 'custom-root-role',
|
||||
description: 'test custom root role',
|
||||
|
||||
@ -707,6 +707,8 @@ export class AccessService {
|
||||
roleType,
|
||||
};
|
||||
|
||||
await this.validatePermissions(role.permissions);
|
||||
|
||||
const rolePermissions = cleanPermissionEnvironment(role.permissions);
|
||||
const newRole = await this.roleStore.create(baseRole);
|
||||
if (rolePermissions) {
|
||||
@ -756,6 +758,8 @@ export class AccessService {
|
||||
description: role.description,
|
||||
roleType,
|
||||
};
|
||||
|
||||
await this.validatePermissions(role.permissions);
|
||||
const rolePermissions = cleanPermissionEnvironment(role.permissions);
|
||||
const updatedRole = await this.roleStore.update(baseRole);
|
||||
const existingPermissions = await this.store.getPermissionsForRole(
|
||||
@ -878,4 +882,33 @@ export class AccessService {
|
||||
async getUserAccessOverview(): Promise<IUserAccessOverview[]> {
|
||||
return this.store.getUserAccessOverview();
|
||||
}
|
||||
|
||||
async validatePermissions(permissions?: PermissionRef[]): Promise<void> {
|
||||
if (!permissions?.length) {
|
||||
return;
|
||||
}
|
||||
const availablePermissions = await this.store.getAvailablePermissions();
|
||||
const invalidPermissions = permissions.filter(
|
||||
(permission) =>
|
||||
!availablePermissions.some((availablePermission) =>
|
||||
'id' in permission
|
||||
? availablePermission.id === permission.id
|
||||
: availablePermission.name === permission.name,
|
||||
),
|
||||
);
|
||||
|
||||
if (invalidPermissions.length > 0) {
|
||||
const invalidPermissionList = invalidPermissions
|
||||
.map((permission) =>
|
||||
'id' in permission
|
||||
? `permission with ID: ${permission.id}`
|
||||
: permission.name,
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
throw new BadDataError(
|
||||
`Invalid permissions supplied. The following permissions don't exist: ${invalidPermissionList}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +64,8 @@ export type IFlagKey =
|
||||
| 'milestoneProgression'
|
||||
| 'featureReleasePlans'
|
||||
| 'plausibleMetrics'
|
||||
| 'safeguards';
|
||||
| 'safeguards'
|
||||
| 'oidcPkceSupport';
|
||||
|
||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||
|
||||
@ -285,6 +286,10 @@ const flags: IFlags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_SAFEGUARDS,
|
||||
false,
|
||||
),
|
||||
oidcPkceSupport: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_OIDC_PKCE_SUPPORT,
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||
|
||||
19
src/migrations/20251118131212-edge-observability-hosting.js
Normal file
@ -0,0 +1,19 @@
|
||||
exports.up = function(db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER TABLE stat_edge_observability
|
||||
ADD COLUMN hosting TEXT;
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function(db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER TABLE stat_edge_observability
|
||||
DROP COLUMN IF EXISTS hosting;
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
@ -1848,3 +1848,56 @@ test('access overview should include users with custom root roles', async () =>
|
||||
expect(userAccess.userId).toBe(user.id);
|
||||
expect(userAccess.rootRole).toBe('Mischievous Messenger');
|
||||
});
|
||||
|
||||
test("creating a role with permissions that don't exist should throw a bad data error", async () => {
|
||||
await expect(() =>
|
||||
accessService.createRole(
|
||||
{
|
||||
name: 'Oogus Boogus',
|
||||
type: CUSTOM_ROOT_ROLE_TYPE,
|
||||
description:
|
||||
"Well, well, well ... what have we here? Sandy Claws, huh? Oooh, I'm really scared!",
|
||||
permissions: [{ name: 'BOGUS' }],
|
||||
createdByUserId: 1,
|
||||
},
|
||||
SYSTEM_USER_AUDIT,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
name: 'BadDataError',
|
||||
message: expect.stringMatching(/BOGUS/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Updating a role with permissions that don't exist should throw a bad data error", async () => {
|
||||
const custom_role = await accessService.createRole(
|
||||
{
|
||||
name: 'Legit custom role',
|
||||
type: CUSTOM_ROOT_ROLE_TYPE,
|
||||
description: '',
|
||||
permissions: [{ name: permissions.CREATE_ADDON }],
|
||||
createdByUserId: 1,
|
||||
},
|
||||
SYSTEM_USER_AUDIT,
|
||||
);
|
||||
await expect(() =>
|
||||
accessService.updateRole(
|
||||
{
|
||||
id: custom_role.id,
|
||||
name: 'Oogus Boogus',
|
||||
type: CUSTOM_ROOT_ROLE_TYPE,
|
||||
description:
|
||||
'This might be the last time that you hear the Boogus song',
|
||||
permissions: [{ name: 'BOGUS' }],
|
||||
createdByUserId: 1,
|
||||
},
|
||||
SYSTEM_USER_AUDIT,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
name: 'BadDataError',
|
||||
message: expect.stringMatching(/BOGUS/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
11
src/test/fixtures/fake-access-store.ts
vendored
@ -18,6 +18,10 @@ import {
|
||||
import FakeRoleStore from './fake-role-store.js';
|
||||
import type { PermissionRef } from '../../lib/services/access-service.js';
|
||||
|
||||
export type FakeAccessStoreConfig = Partial<{
|
||||
availablePermissions: IPermission[];
|
||||
}>;
|
||||
|
||||
export class FakeAccessStore implements IAccessStore {
|
||||
fakeRolesStore: IRoleStore;
|
||||
|
||||
@ -25,8 +29,11 @@ export class FakeAccessStore implements IAccessStore {
|
||||
|
||||
rolePermissions: Map<number, IPermission[]> = new Map();
|
||||
|
||||
constructor(roleStore?: IRoleStore) {
|
||||
availablePermissions: IPermission[] = [];
|
||||
|
||||
constructor(roleStore?: IRoleStore, config?: FakeAccessStoreConfig) {
|
||||
this.fakeRolesStore = roleStore ?? new FakeRoleStore();
|
||||
this.availablePermissions = config?.availablePermissions ?? [];
|
||||
}
|
||||
|
||||
getProjectUserAndGroupCountsForRole(
|
||||
@ -112,7 +119,7 @@ export class FakeAccessStore implements IAccessStore {
|
||||
}
|
||||
|
||||
getAvailablePermissions(): Promise<IPermission[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
return Promise.resolve(this.availablePermissions);
|
||||
}
|
||||
|
||||
getPermissionsForUser(userId: Number): Promise<IUserPermission[]> {
|
||||
|
||||
@ -46,7 +46,7 @@ Each project must always have **at least one** active environment.
|
||||
|
||||
Environments are adjacent to [feature flags](../reference/feature-toggles) in Unleash: neither one contains the other, but they come together to let you define activation strategies.
|
||||
|
||||

|
||||

|
||||
|
||||
:::info Environments and API keys
|
||||
|
||||
@ -64,10 +64,10 @@ When creating a feature flag, you must assign a unique (across your Unleash inst
|
||||
|
||||
## Activation strategies
|
||||
|
||||

|
||||
|
||||
[**Activation strategies**](../reference/activation-strategies) (or just **strategies** for short) are the part of feature flags that tell Unleash **who should get a feature**. An activation strategy is assigned to **one **feature flag in **one **environment.
|
||||
|
||||

|
||||
|
||||
When you check a [feature flag](../reference/feature-toggles) in an application, the following decides the result:
|
||||
|
||||
1. Is the flag active in the current environment? If not, it will be disabled.
|
||||
@ -122,6 +122,32 @@ Segments are only available to [Pro](/availability#plans) and [Enterprise](https
|
||||
|
||||

|
||||
|
||||
## Release templates
|
||||
|
||||
[Release templates](/reference/release-templates) provide a way to standardize how you roll out features across your application. While activation strategies define who gets a feature, release templates define how that access expands over time.
|
||||
|
||||

|
||||
|
||||
A release template is a blueprint for a rollout. It consists of sequential stages called **milestones**. Each milestone contains one or more **activation strategies** or **segments**. For example, a template might look like this:
|
||||
|
||||
- Milestone 1: Internal users only.
|
||||
- Milestone 2: Beta users.
|
||||
- Milestone 3: 50% of all users.
|
||||
- Milestone 4: 100% of all users.
|
||||
|
||||

|
||||
|
||||
When you apply a release template to a specific feature flag in an environment, it creates a **release plan**. This plan lets you progress through the milestones step-by-step. Release templates ensure that your rollouts are consistent and safe, removing the need to manually configure strategies from scratch every time you launch a new feature.
|
||||
|
||||

|
||||
|
||||
### Automatic milestone progression and safeguards
|
||||
|
||||
You can further automate your release plans using [impact metrics](/reference/impact-metrics). Impact metrics are data points sent from your application (such as error counts, memory usage, or response latency) that act as real-time health indicators for your feature.
|
||||
|
||||
By defining safeguards based on these metrics, you can allow Unleash to manage the rollout for you. If metrics remain healthy for a set duration, Unleash automatically advances to the next milestone.
|
||||
If a metric crosses a safety threshold (for example, error rate spikes), Unleash immediately pauses the rollout to prevent incidents.
|
||||
|
||||
## Variants and feature flag payloads
|
||||
|
||||
By default, a [feature flag](../reference/feature-toggles) in Unleash only tells you whether a feature is enabled or disabled, but you can also add more information to your flags by using [**feature flag variants**](../reference/feature-toggle-variants). Variants also allow you to run [A/B testing experiments](../../feature-flag-tutorials/use-cases/a-b-testing).
|
||||
@ -132,19 +158,11 @@ When you create new variants for a feature, they must be given a name and a **we
|
||||
|
||||
You can use the variant payload to attach arbitrary data to a variant. Variants can have different kinds of payloads.
|
||||
|
||||
A feature flag can have as many variants as you want.
|
||||
|
||||
### Variants and environments
|
||||
|
||||
Prior to 4.21, variants were independent of [environments](../reference/environments). In other words: if you're on 4.19 or lower, you’ll always have the exact same variants with the exact same weightings and the exact same payloads in all environments.
|
||||
|
||||

|
||||
|
||||
As of version 4.21, a feature can have different variants in different environments. For instance, a development environment might have no variants, while a production environment has 2 variants. Payloads, weightings, and anything else can also differ between environments.
|
||||
A feature can have different variants in different environments. For instance, a development environment might have no variants, while a production environment has 2 variants. Payloads, weightings, and anything else can also differ between environments.
|
||||
|
||||

|
||||
|
||||
## Use case: changing website colors {#use-case}
|
||||
## Use case: changing website colors
|
||||
|
||||
Using the concepts we have looked at in the previous sections, let’s create a hypothetical case and see how Unleash would solve it.
|
||||
|
||||
@ -205,4 +223,4 @@ if (theme === “green”) {
|
||||
}
|
||||
```
|
||||
|
||||
Now users that are included in the gradual rollout will get one of the three themes. Users that aren’t included get the old theme.
|
||||
Now users that are included in the gradual rollout will get one of the three themes. Users that aren’t included get the old theme.
|
||||
189
website/docs/reference/impact-metrics.mdx
Normal file
@ -0,0 +1,189 @@
|
||||
---
|
||||
title: Impact metrics
|
||||
---
|
||||
|
||||
import SearchPriority from '@site/src/components/SearchPriority';
|
||||
|
||||
<SearchPriority level="high" />
|
||||
|
||||
<details>
|
||||
<summary>Impact metrics is an early access feature</summary>
|
||||
|
||||
Impact metrics and automated release progression are early access features. Functionality may change. We are actively looking for feedback. Share your experience in the Unleash community Slack or email beta@getunleash.io.
|
||||
|
||||
During this early access period, Impact Metrics are available for Unleash Hosted customers.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
Impact metrics are lightweight, application-level time-series metrics stored and visualized directly inside Unleash.
|
||||
They allow you to connect specific application data, such as request counts, error rates, or memory usage, to your feature flags and release plans.
|
||||
|
||||
Use impact metrics to validate feature impact and automate your release process. For example, you can monitor usage patterns or performance to see if a feature is meeting its goals.
|
||||
|
||||
By combining impact metrics with [release templates](/reference/release-templates), you can reduce manual release operations.
|
||||
Unleash can monitor your rollout and automatically progress to the next milestone or trigger safeguards that pause the release based on the health of your metrics.
|
||||
|
||||

|
||||
|
||||
Impact metrics support three types of data:
|
||||
- **Counters**: Cumulative values that only increase. These are suitable for request counts, error counts, or event counters.
|
||||
- **Gauges**: Values that fluctuate up or down, such as memory usage or the number of active users.
|
||||
- **Histograms**: Distribution of values, useful for measuring things like request duration or response size. Supports percentiles (p50, p95, p99).
|
||||
|
||||
You can use these metrics in two primary areas:
|
||||
- **Charts**: Visualize data in the Impact Metrics section of the Admin UI.
|
||||
- **Release management**: Drive automatic milestone progression or trigger safeguards to stop a rollout.
|
||||
|
||||
## Key use cases
|
||||
|
||||
Impact metrics provide real-time data about your features. This enables two primary workflows: [automated releases with safeguards](#automated-releases-with-safeguards) and [feature impact validation](#validate-feature-impact-and-improve-iteratively).
|
||||
|
||||
### Automated releases with safeguards
|
||||
|
||||
Impact metrics integrate directly with release templates so Unleash can automatically progress milestones or pause a rollout when metrics fall outside your defined thresholds. This reduces the manual effort required during releases, especially when teams are shipping more frequently or outside working hours.
|
||||
|
||||
Instead of tracking dashboards or waiting for alerts, you can use counters, gauges, or histograms to define what “healthy” looks like and let Unleash manage the rollout based on objective data.
|
||||
|
||||
Examples:
|
||||
|
||||
- Progress from 25% → 50% only if error rates remain below a threshold.
|
||||
- Automatically pause when request latency increases during a rollout.
|
||||
|
||||
See [Automate release progression](#automate-release-progression) and [Configure safeguards](#configure-safeguards) for more information.
|
||||
|
||||
### Validate feature impact and improve iteratively
|
||||
|
||||
Beyond automation, impact metrics help you understand whether the features you build are actually solving the right problems. You can track usage patterns, performance trends, or flag-correlated outcomes over time to evaluate whether a feature is meeting expectations. This supports both short-term decisions during a rollout and longer-term decisions about iteration, refinement, or deprecation.
|
||||
|
||||
Examples:
|
||||
|
||||
- Track whether a new feature is being used as expected after rollout.
|
||||
- Monitor error counts, traffic patterns, or operational health for a feature or its variants.
|
||||
- Identify features that need follow-up work, optimisation, or removal.
|
||||
|
||||
See [Create impact metrics charts](#create-impact-metrics-charts) for more information.
|
||||
|
||||
## Define and record metrics in the SDK
|
||||
|
||||
:::info
|
||||
Impact metrics are currently supported by the Node SDK. To request support for additional SDKs, please contact [beta@getunleash.io](mailto:beta@getunleash.io).
|
||||
:::
|
||||
|
||||
To visualize a metric in Unleash, you must first define the metric in the SDK and then count or record values for it.
|
||||
|
||||
The SDK automatically attaches the following context labels to your metrics to ensure they are queryable in the UI: `appName`, `environment`, `origin` (for example, `origin=sdk` or `origin=Edge`).
|
||||
|
||||
### Counters
|
||||
|
||||
Use counters for cumulative values that only increase, such as the total number of requests or errors.
|
||||
|
||||
```javascript
|
||||
// 1. Define the counter
|
||||
unleash.impactMetrics.defineCounter(
|
||||
'request_count',
|
||||
'Total number of HTTP requests processed'
|
||||
);
|
||||
|
||||
// 2. Increment the counter
|
||||
unleash.impactMetrics.incrementCounter('request_count');
|
||||
```
|
||||
|
||||
### Gauges
|
||||
|
||||
Use gauges for values that can go up and down, such as current memory usage or active thread count.
|
||||
|
||||
```javascript
|
||||
// 1. Define the gauge
|
||||
unleash.impactMetrics.defineGauge(
|
||||
'heap_memory_total',
|
||||
'Current heap memory usage in bytes'
|
||||
);
|
||||
|
||||
// 2. Update the gauge value
|
||||
const currentHeap = process.memoryUsage().heapUsed;
|
||||
unleash.impactMetrics.updateGauge('heap_memory_total', currentHeap);
|
||||
```
|
||||
|
||||
### Histograms
|
||||
|
||||
Use histograms to measure the distribution of values, such as request duration or response size. Unleash automatically calculates percentiles (p50, p95, p99).
|
||||
|
||||
```javascript
|
||||
// 1. Define the histogram
|
||||
unleash.impactMetrics.defineHistogram(
|
||||
'request_time_ms',
|
||||
'Time taken to process a request in milliseconds'
|
||||
);
|
||||
|
||||
// 2. Record a value
|
||||
const duration = 125;
|
||||
unleash.impactMetrics.observeHistogram('request_time_ms', duration);
|
||||
```
|
||||
|
||||
## Create impact metrics charts
|
||||
|
||||
You can visualize your defined metrics in the Unleash Admin UI.
|
||||
|
||||
1. Go to **Impact Metrics** in the sidebar.
|
||||
2. Click **New Chart**.
|
||||
3. In the **Add New Chart** dialog, configure the following:
|
||||
- Data series: Select your counter or gauge (for example, `request_count`).
|
||||
- Time: Select a window (for example, Last 24 hours).
|
||||
- Mode (aggregation type): Choose how to display the data.
|
||||
- For Counters:
|
||||
- Rate per second
|
||||
- Count
|
||||
- For Gauges:
|
||||
- Sum
|
||||
- Average
|
||||
- For Histograms:
|
||||
- p50
|
||||
- p95
|
||||
- p99
|
||||
- Filters: Optionally filter by `appName`, `environment`, `origin`.
|
||||
|
||||
4. Click **Add chart**.
|
||||
|
||||

|
||||
|
||||
The chart will display the data based on your configuration. Note that there is a 1–2 minute delay between data generation and visualization due to the scrape and ingestion cycle.
|
||||
|
||||
## Automate release progression
|
||||
|
||||
Impact metrics integrate with [release templates](/reference/release-templates) to automate the rollout process. Instead of manually updating milestones (for example, moving from 10% to 50%), Unleash can handle this for you.
|
||||
|
||||
To configure automatic progression:
|
||||
|
||||
1. Open a feature flag that [uses a release template](https://docs.getunleash.io/reference/release-templates#apply-a-release-template-to-a-feature-flag).
|
||||
2. Select a milestone and click **Add automation**.
|
||||
3. Define the conditions:
|
||||
- Time: The minimum duration the milestone must run. For example, proceed after 24 hours.
|
||||
4. Click **Save**.
|
||||
|
||||
When the time conditions are satisfied, Unleash automatically advances the release to the next milestone.
|
||||
|
||||
## Configure safeguards
|
||||
|
||||
Safeguards act as a safety net for your releases. They can automatically pause a rollout if metrics indicate system instability.
|
||||
|
||||
:::info
|
||||
|
||||
To experiment with safeguards during the early access phase, please reach out to beta@getunleash.io for configuration guidance.
|
||||
|
||||
:::
|
||||
|
||||
|
||||
## Technical implementation details
|
||||
|
||||
### Ingestion and batching
|
||||
|
||||
Impact metrics are batched and sent on the same interval as [regular SDK metrics](/reference/impression-data). They are ingested via the regular metrics endpoint.
|
||||
|
||||
### Unleash Edge behavior
|
||||
|
||||
Unleash Edge forwards impact metrics received from SDKs to the Unleash API. The origin of the label appears as `origin=Edge`. Daisy-chaining Edge instances is not supported.
|
||||
|
||||
If an Edge instance accumulates a large batch of metrics (e.g., due to a temporary network disconnect), it will send them as a single bulk send upon reconnection. This will appear as a large, sudden spike in your counter graphs, rather than a smooth distribution over time. This is expected behavior.
|
||||
@ -97,7 +97,19 @@ const sidebars: SidebarsConfig = {
|
||||
'reference/segments',
|
||||
'reference/unleash-context',
|
||||
'reference/stickiness',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Release management',
|
||||
collapsed: true,
|
||||
type: 'category',
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'reference/release-templates',
|
||||
},
|
||||
items: [
|
||||
'reference/release-templates',
|
||||
'reference/impact-metrics',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
BIN
website/static/img/add-new-impact-metrics-chart.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 58 KiB |
BIN
website/static/img/anatomy-of-unleash-environments.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 313 KiB |
BIN
website/static/img/anatomy-of-unleash-release-template-apply.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
website/static/img/anatomy-of-unleash-release-template.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 61 KiB |
BIN
website/static/img/impact-metrics.png
Normal file
|
After Width: | Height: | Size: 334 KiB |