1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00
This commit is contained in:
sjaanus 2023-10-24 09:56:55 +03:00
commit f27c4350b2
No known key found for this signature in database
GPG Key ID: 20E007C0248BA7FF
45 changed files with 295 additions and 325 deletions

View File

@ -23,6 +23,7 @@ interface IConstraintAccordionViewProps {
onEdit?: () => void;
sx?: SxProps<Theme>;
compact?: boolean;
disabled?: boolean;
renderAfter?: JSX.Element;
}
@ -68,6 +69,7 @@ export const ConstraintAccordionView = ({
onDelete,
sx = undefined,
compact = false,
disabled = false,
renderAfter,
}: IConstraintAccordionViewProps) => {
const [expandable, setExpandable] = useState(true);
@ -102,6 +104,7 @@ export const ConstraintAccordionView = ({
onDelete={onDelete}
singleValue={singleValue}
allowExpand={setExpandable}
disabled={disabled}
expanded={expanded}
compact={compact}
/>

View File

@ -13,6 +13,7 @@ interface IConstraintAccordionViewHeaderProps {
expanded: boolean;
allowExpand: (shouldExpand: boolean) => void;
compact?: boolean;
disabled?: boolean;
}
const StyledContainer = styled('div')(({ theme }) => ({
@ -34,6 +35,7 @@ export const ConstraintAccordionViewHeader = ({
allowExpand,
expanded,
compact,
disabled,
}: IConstraintAccordionViewHeaderProps) => {
const { context } = useUnleashContext();
const { contextName } = constraint;
@ -44,12 +46,13 @@ export const ConstraintAccordionViewHeader = ({
return (
<StyledContainer>
<ConstraintIcon compact={compact} />
<ConstraintIcon compact={compact} disabled={disabled} />
<ConstraintAccordionViewHeaderInfo
constraint={constraint}
singleValue={singleValue}
allowExpand={allowExpand}
expanded={expanded}
disabled={disabled}
/>
<ConstraintAccordionHeaderActions
onEdit={onEdit}

View File

@ -50,6 +50,7 @@ interface ConstraintAccordionViewHeaderMetaInfoProps {
singleValue: boolean;
expanded: boolean;
allowExpand: (shouldExpand: boolean) => void;
disabled?: boolean;
maxLength?: number;
}
@ -58,23 +59,34 @@ export const ConstraintAccordionViewHeaderInfo = ({
singleValue,
allowExpand,
expanded,
disabled = false,
maxLength = 112, //The max number of characters in the values text for NOT allowing expansion
}: ConstraintAccordionViewHeaderMetaInfoProps) => {
return (
<StyledHeaderWrapper>
<StyledHeaderMetaInfo>
<Tooltip title={constraint.contextName} arrow>
<StyledHeaderText>
<StyledHeaderText
sx={(theme) => ({
color: disabled
? theme.palette.text.secondary
: 'inherit',
})}
>
{constraint.contextName}
</StyledHeaderText>
</Tooltip>
<ConstraintViewHeaderOperator constraint={constraint} />
<ConstraintViewHeaderOperator
constraint={constraint}
disabled={disabled}
/>
<ConditionallyRender
condition={singleValue}
show={
<ConstraintAccordionViewHeaderSingleValue
constraint={constraint}
allowExpand={allowExpand}
disabled={disabled}
/>
}
elseShow={
@ -83,6 +95,7 @@ export const ConstraintAccordionViewHeaderInfo = ({
expanded={expanded}
allowExpand={allowExpand}
maxLength={maxLength}
disabled={disabled}
/>
}
/>

View File

@ -22,6 +22,7 @@ interface ConstraintSingleValueProps {
expanded: boolean;
maxLength: number;
allowExpand: (shouldExpand: boolean) => void;
disabled?: boolean;
}
const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
@ -55,6 +56,7 @@ export const ConstraintAccordionViewHeaderMultipleValues = ({
expanded,
allowExpand,
maxLength,
disabled = false,
}: ConstraintSingleValueProps) => {
const [expandable, setExpandable] = useState(false);
@ -72,7 +74,15 @@ export const ConstraintAccordionViewHeaderMultipleValues = ({
return (
<StyledHeaderValuesContainerWrapper>
<StyledHeaderValuesContainer>
<StyledValuesSpan>{text}</StyledValuesSpan>
<StyledValuesSpan
sx={(theme) => ({
color: disabled
? theme.palette.text.secondary
: 'inherit',
})}
>
{text}
</StyledValuesSpan>
<ConditionallyRender
condition={expandable}
show={

View File

@ -15,6 +15,7 @@ const StyledSingleValueChip = styled(Chip)(({ theme }) => ({
interface ConstraintSingleValueProps {
constraint: IConstraint;
allowExpand: (shouldExpand: boolean) => void;
disabled?: boolean;
}
const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
@ -26,6 +27,7 @@ const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
export const ConstraintAccordionViewHeaderSingleValue = ({
constraint,
allowExpand,
disabled = false,
}: ConstraintSingleValueProps) => {
const { locationSettings } = useLocationSettings();
@ -36,6 +38,9 @@ export const ConstraintAccordionViewHeaderSingleValue = ({
return (
<StyledHeaderValuesContainerWrapper>
<StyledSingleValueChip
sx={(theme) => ({
color: disabled ? theme.palette.text.secondary : 'inherit',
})}
label={formatConstraintValue(constraint, locationSettings)}
/>
</StyledHeaderValuesContainerWrapper>

View File

@ -10,6 +10,7 @@ import { oneOf } from 'utils/oneOf';
interface ConstraintViewHeaderOperatorProps {
constraint: IConstraint;
disabled?: boolean;
}
const StyledHeaderValuesContainerWrapper = styled('div')(({ theme }) => ({
@ -28,6 +29,7 @@ const StyledHeaderConstraintContainer = styled('div')(({ theme }) => ({
export const ConstraintViewHeaderOperator = ({
constraint,
disabled = false,
}: ConstraintViewHeaderOperatorProps) => {
return (
<StyledHeaderValuesContainerWrapper>
@ -47,6 +49,7 @@ export const ConstraintViewHeaderOperator = ({
<ConstraintOperator
constraint={constraint}
hasPrefix={Boolean(constraint.inverted)}
disabled={disabled}
/>
</StyledHeaderConstraintContainer>
<ConditionallyRender

View File

@ -4,18 +4,24 @@ import { TrackChanges } from '@mui/icons-material';
interface IConstraintIconProps {
compact?: boolean;
disabled?: boolean;
}
export const ConstraintIcon: VFC<IConstraintIconProps> = ({ compact }) => (
export const ConstraintIcon: VFC<IConstraintIconProps> = ({
compact,
disabled,
}) => (
<Box
sx={{
backgroundColor: 'primary.light',
sx={(theme) => ({
backgroundColor: disabled
? theme.palette.neutral.border
: 'primary.light',
p: compact ? '1px' : '2px',
borderRadius: '50%',
width: compact ? '18px' : '24px',
height: compact ? '18px' : '24px',
marginRight: '13px',
}}
})}
>
<TrackChanges
sx={(theme) => ({

View File

@ -6,6 +6,7 @@ import { styled } from '@mui/material';
interface IConstraintOperatorProps {
constraint: IConstraint;
hasPrefix?: boolean;
disabled?: boolean;
}
const StyledContainer = styled('div')(({ theme }) => ({
@ -15,19 +16,25 @@ const StyledContainer = styled('div')(({ theme }) => ({
lineHeight: 1.25,
}));
const StyledName = styled('div')(({ theme }) => ({
const StyledName = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled: boolean }>(({ theme, disabled }) => ({
fontSize: theme.fontSizes.smallBody,
lineHeight: 17 / 14,
color: disabled ? theme.palette.text.secondary : theme.palette.text.primary,
}));
const StyledText = styled('div')(({ theme }) => ({
const StyledText = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled: boolean }>(({ theme, disabled }) => ({
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.neutral.main,
color: disabled ? theme.palette.text.secondary : theme.palette.neutral.main,
}));
export const ConstraintOperator = ({
constraint,
hasPrefix,
disabled = false,
}: IConstraintOperatorProps) => {
const operatorName = constraint.operator;
const operatorText = formatOperatorDescription(constraint.operator);
@ -40,8 +47,8 @@ export const ConstraintOperator = ({
paddingLeft: hasPrefix ? 0 : undefined,
}}
>
<StyledName>{operatorName}</StyledName>
<StyledText>{operatorText}</StyledText>
<StyledName disabled={disabled}>{operatorName}</StyledName>
<StyledText disabled={disabled}>{operatorText}</StyledText>
</StyledContainer>
);
};

View File

@ -0,0 +1,46 @@
import { useTheme } from '@mui/material';
import { CSSProperties } from 'react';
interface IPercentageCircleProps {
percentage: number;
size?: `${number}rem`;
}
const PercentageCircle = ({
percentage,
size = '4rem',
}: IPercentageCircleProps) => {
const theme = useTheme();
const style: CSSProperties = {
display: 'block',
borderRadius: '100%',
transform: 'rotate(-90deg)',
height: size,
width: size,
background: theme.palette.background.elevation2,
};
// The percentage circle used to be drawn by CSS with a conic-gradient,
// but the result was either jagged or blurry. SVG seems to look better.
// See https://stackoverflow.com/a/70659532.
const radius = 100 / (2 * Math.PI);
const diameter = 2 * radius;
return (
<svg viewBox={`0 0 ${diameter} ${diameter}`} style={style} aria-hidden>
<title>A circle progress bar with {percentage}% completion.</title>
<circle
r={radius}
cx={radius}
cy={radius}
fill='none'
stroke={theme.palette.neutral.border}
strokeWidth={diameter}
strokeDasharray={`${percentage} 100`}
/>
</svg>
);
};
export default PercentageCircle;

View File

@ -16,6 +16,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
interface ISegmentItemProps {
segment: Partial<ISegment>;
isExpanded?: boolean;
disabled?: boolean;
constraintList?: JSX.Element;
headerContent?: JSX.Element;
}
@ -49,20 +50,33 @@ const StyledLink = styled(Link)(({ theme }) => ({
textDecoration: 'underline',
},
}));
const StyledText = styled('span', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled: boolean }>(({ theme, disabled }) => ({
color: disabled ? theme.palette.text.secondary : 'inherit',
}));
export const SegmentItem: VFC<ISegmentItemProps> = ({
segment,
isExpanded,
headerContent,
constraintList,
disabled = false,
}) => {
const [isOpen, setIsOpen] = useState(isExpanded || false);
return (
<StyledAccordion expanded={isOpen}>
<StyledAccordionSummary id={`segment-accordion-${segment.id}`}>
<DonutLarge color='secondary' sx={{ mr: 1 }} />
<span>Segment:</span>
<DonutLarge
sx={(theme) => ({
mr: 1,
color: disabled
? theme.palette.neutral.border
: theme.palette.secondary.main,
})}
/>
<StyledText disabled={disabled}>Segment:</StyledText>
<StyledLink to={`/segments/edit/${segment.id}`}>
{segment.name}
</StyledLink>

View File

@ -9,8 +9,6 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { styled } from '@mui/material';
import { ConstraintAccordionView } from 'component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
import { ConstraintError } from './ConstraintError/ConstraintError';
import { ConstraintOk } from './ConstraintOk/ConstraintOk';
interface IConstraintExecutionWithoutResultsProps {
constraints?: PlaygroundConstraintSchema[];
@ -35,7 +33,11 @@ export const ConstraintExecutionWithoutResults: VFC<
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
<ConstraintAccordionView constraint={constraint} compact />
<ConstraintAccordionView
constraint={constraint}
compact
disabled
/>
</Fragment>
))}
</ConstraintExecutionWrapper>

View File

@ -52,6 +52,7 @@ export const DisabledStrategyExecution: VFC<IDisabledStrategyExecutionProps> =
parameters={parameters}
constraints={constraints}
input={input}
disabled
/>
),
hasCustomStrategyParameters && (
@ -61,9 +62,14 @@ export const DisabledStrategyExecution: VFC<IDisabledStrategyExecutionProps> =
/>
),
name === 'default' && (
<StyledBoxSummary sx={{ width: '100%' }}>
The standard strategy is <Badge color='success'>ON</Badge>{' '}
for all users.
<StyledBoxSummary
sx={(theme) => ({
width: '100%',
color: theme.palette.text.secondary,
})}
>
The standard strategy is{' '}
<Badge color={'disabled'}>ON</Badge> for all users.
</StyledBoxSummary>
),
].filter(Boolean);
@ -74,7 +80,12 @@ export const DisabledStrategyExecution: VFC<IDisabledStrategyExecutionProps> =
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<Fragment key={index}>
<ConditionallyRender
condition={index > 0}
condition={
index > 0 &&
(strategyResult.name === 'flexibleRollout'
? index < items.length
: index < items.length - 1)
}
show={<StrategySeparator text='AND' />}
/>
{item}

View File

@ -8,6 +8,7 @@ interface IConstraintItemProps {
text: string;
input?: string | number | boolean | 'no value';
showReason?: boolean;
disabled?: boolean;
}
const StyledDivContainer = styled('div', {
@ -34,12 +35,15 @@ const StyledChip = styled(Chip)(({ theme }) => ({
margin: theme.spacing(0.5),
}));
const StyledParagraph = styled('p')(({ theme }) => ({
const StyledParagraph = styled('p', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled: boolean }>(({ theme, disabled }) => ({
display: 'inline',
margin: theme.spacing(0.5, 0),
maxWidth: '95%',
textAlign: 'center',
wordBreak: 'break-word',
color: disabled ? theme.palette.text.secondary : 'inherit',
}));
export const PlaygroundParameterItem = ({
@ -47,10 +51,11 @@ export const PlaygroundParameterItem = ({
text,
input,
showReason = false,
disabled = false,
}: IConstraintItemProps) => {
const theme = useTheme();
const color = input === 'no value' ? 'error' : 'neutral';
const color = input === 'no value' && !disabled ? 'error' : 'neutral';
const reason = `value does not match any ${text}`;
return (
@ -64,7 +69,11 @@ export const PlaygroundParameterItem = ({
show={
<Typography
variant='subtitle1'
color={theme.palette.error.main}
color={
disabled
? theme.palette.text.secondary
: theme.palette.error.main
}
>
{reason}
</Typography>
@ -75,7 +84,7 @@ export const PlaygroundParameterItem = ({
show={<p>No {text}s added yet.</p>}
elseShow={
<div>
<StyledParagraph>
<StyledParagraph disabled={disabled}>
{value.length}{' '}
{value.length > 1 ? `${text}s` : text} will get
access.
@ -83,6 +92,7 @@ export const PlaygroundParameterItem = ({
{value.map((v: string | number) => (
<StyledChip
key={v}
disabled={disabled}
label={
<StringTruncator
maxWidth='300'
@ -98,7 +108,9 @@ export const PlaygroundParameterItem = ({
</StyledDivColumn>
<ConditionallyRender
condition={Boolean(showReason)}
show={<CancelOutlined color={'error'} />}
show={
<CancelOutlined color={disabled ? 'disabled' : 'error'} />
}
elseShow={<div />}
/>
</StyledDivContainer>

View File

@ -29,6 +29,7 @@ export const SegmentExecutionWithoutResult: VFC<
/>
}
isExpanded
disabled
/>
<ConditionallyRender
condition={

View File

@ -71,7 +71,12 @@ export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<Fragment key={index}>
<ConditionallyRender
condition={index > 0}
condition={
index > 0 &&
(strategyResult.name === 'flexibleRollout'
? index < items.length
: index < items.length - 1)
}
show={<StrategySeparator text='AND' />}
/>
{item}

View File

@ -2,24 +2,34 @@ import {
parseParameterNumber,
parseParameterStrings,
} from 'utils/parseParameter';
import { Box } from '@mui/material';
import { Box, styled } from '@mui/material';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { PlaygroundParameterItem } from '../PlaygroundParameterItem/PlaygroundParameterItem';
import { StyledBoxSummary } from '../StrategyExecution.styles';
import { PlaygroundConstraintSchema, PlaygroundRequestSchema } from 'openapi';
import { getMappedParam } from '../helpers';
import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import DisabledPercentageCircle from 'component/common/PercentageCircle/DisabledPercentageCircle';
export interface PlaygroundResultStrategyExecutionParametersProps {
parameters: { [key: string]: string };
constraints: PlaygroundConstraintSchema[];
input?: PlaygroundRequestSchema;
disabled?: boolean;
}
const StyledText = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})<{ disabled: boolean }>(({ theme, disabled }) => ({
color: disabled ? theme.palette.text.secondary : theme.palette.neutral.main,
}));
export const PlaygroundResultStrategyExecutionParameters = ({
parameters,
constraints,
input,
disabled = false,
}: PlaygroundResultStrategyExecutionParametersProps) => {
return (
<>
@ -35,20 +45,44 @@ export const PlaygroundResultStrategyExecutionParameters = ({
key={key}
sx={{ display: 'flex', alignItems: 'center' }}
>
<Box sx={{ mr: '1rem' }}>
<PercentageCircle
percentage={percentage}
size='2rem'
<Box
sx={(theme) => ({
mr: '1rem',
color: disabled
? theme.palette.neutral.border
: theme.palette.text.secondary,
})}
>
<ConditionallyRender
condition={disabled}
show={
<DisabledPercentageCircle
percentage={percentage}
size='2rem'
/>
}
elseShow={
<PercentageCircle
percentage={percentage}
size='2rem'
/>
}
/>
</Box>
<div>
<Badge color='success'>{percentage}%</Badge>{' '}
<StyledText disabled={disabled}>
<Badge
color={
disabled ? 'disabled' : 'success'
}
>
{percentage}%
</Badge>{' '}
of your base{' '}
{constraints.length > 0
? 'who match constraints'
: ''}{' '}
is included.
</div>
</StyledText>
</StyledBoxSummary>
);
}
@ -87,6 +121,7 @@ export const PlaygroundResultStrategyExecutionParameters = ({
text={'host'}
input={'no value'}
showReason={undefined}
disabled={disabled}
/>
);
}
@ -97,6 +132,7 @@ export const PlaygroundResultStrategyExecutionParameters = ({
key={key}
value={IPs}
text={'IP'}
disabled={disabled}
input={
input?.context?.[getMappedParam(key)]
? input?.context?.[getMappedParam(key)]

View File

@ -441,28 +441,11 @@ export const ProjectAccessAssign = ({
Select the role to assign for this project
</StyledInputDescription>
<StyledAutocompleteWrapper>
<ConditionallyRender
condition={Boolean(
uiConfig.flags.multipleRoles,
)}
show={() => (
<MultipleRoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
value={selectedRoles}
setValue={setRoles}
/>
)}
elseShow={() => (
<RoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
value={selectedRoles[0]}
setValue={(role) =>
setRoles(role ? [role] : [])
}
/>
)}
<MultipleRoleSelect
data-testid={PA_ROLE_ID}
roles={roles}
value={selectedRoles}
setValue={setRoles}
/>
</StyledAutocompleteWrapper>
</div>

View File

@ -106,7 +106,6 @@ exports[`should create default config 1`] = `
},
},
"migrationLock": true,
"multipleRoles": false,
"personalAccessTokensKillSwitch": false,
"playgroundImprovements": false,
"privateProjects": false,
@ -151,7 +150,6 @@ exports[`should create default config 1`] = `
},
},
"migrationLock": true,
"multipleRoles": false,
"personalAccessTokensKillSwitch": false,
"playgroundImprovements": false,
"privateProjects": false,

View File

@ -51,8 +51,6 @@ export default class ClientInstanceStore implements IClientInstanceStore {
private metricTimer: Function;
private timer: Timeout;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.eventBus = eventBus;
@ -197,7 +195,5 @@ export default class ClientInstanceStore implements IClientInstanceStore {
return this.db(TABLE).where('app_name', appName).del();
}
destroy(): void {
clearInterval(this.timer);
}
destroy(): void {}
}

View File

@ -24,9 +24,6 @@ async function getSetup() {
base,
clientFeatureToggleStore: stores.clientFeatureToggleStore,
request: supertest(app),
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
@ -43,7 +40,6 @@ const callGetAll = async (controller: FeatureController) => {
let base;
let request;
let destroy;
let flagResolver;
@ -51,7 +47,6 @@ beforeEach(async () => {
const setup = await getSetup();
base = setup.base;
request = setup.request;
destroy = setup.destroy;
flagResolver = {
isEnabled: () => {
return false;
@ -59,10 +54,6 @@ beforeEach(async () => {
};
});
afterEach(() => {
destroy();
});
test('should get empty getFeatures via client', () => {
expect.assertions(1);
return request

View File

@ -4,7 +4,12 @@ import { createTestConfig } from '../../test/config/test-config';
import FakeEventStore from '../../test/fixtures/fake-event-store';
import { randomId } from '../util/random-id';
import FakeProjectStore from '../../test/fixtures/fake-project-store';
import { EventService, ProxyService, SettingService } from '../../lib/services';
import {
EventService,
ProxyService,
SchedulerService,
SettingService,
} from '../../lib/services';
import { ISettingStore } from '../../lib/types';
import { frontendSettingsKey } from '../../lib/types/settings/frontend-settings';
import { minutesToMilliseconds } from 'date-fns';
@ -55,7 +60,6 @@ test('corsOriginMiddleware origin validation', async () => {
userName,
),
).rejects.toThrow('Invalid origin: a');
proxyService.destroy();
});
test('corsOriginMiddleware without config', async () => {
@ -82,7 +86,6 @@ test('corsOriginMiddleware without config', async () => {
expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: [],
});
proxyService.destroy();
});
test('corsOriginMiddleware with config', async () => {
@ -109,12 +112,9 @@ test('corsOriginMiddleware with config', async () => {
expect(await proxyService.getFrontendSettings(false)).toEqual({
frontendApiOrigins: ['*'],
});
proxyService.destroy();
});
test('corsOriginMiddleware with caching enabled', async () => {
jest.useFakeTimers();
const { proxyService } = createSettingService([]);
const userName = randomId();
@ -133,24 +133,11 @@ test('corsOriginMiddleware with caching enabled', async () => {
frontendApiOrigins: [],
});
jest.advanceTimersByTime(minutesToMilliseconds(2));
await proxyService.fetchFrontendSettings(); // called by the scheduler service
jest.useRealTimers();
const settings = await proxyService.getFrontendSettings();
/*
This is needed because it is not enough to fake time to test the
updated cache, we also need to make sure that all promises are
executed and completed, in the right order.
*/
await new Promise<void>((resolve) =>
process.nextTick(async () => {
const settings = await proxyService.getFrontendSettings();
expect(settings).toEqual({
frontendApiOrigins: ['*'],
});
resolve();
}),
);
proxyService.destroy();
expect(settings).toEqual({
frontendApiOrigins: ['*'],
});
});

View File

@ -28,25 +28,16 @@ async function getSetup() {
return {
base,
request: supertest(app),
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
let request;
let base;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
request = setup.request;
base = setup.base;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
test('should get ui config', async () => {

View File

@ -20,25 +20,16 @@ async function getSetup() {
return {
base,
request: supertest(app),
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
let base;
let request;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
base = setup.base;
request = setup.request;
destroy = setup.destroy;
});
afterEach(async () => {
await destroy();
});
test('should get all context definitions', () => {

View File

@ -19,25 +19,16 @@ async function getSetup() {
stores,
perms,
config,
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
let stores;
let request;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
stores = setup.stores;
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
test('/api/admin/metrics/seen-toggles is deprecated', () => {

View File

@ -33,16 +33,11 @@ describe('Public Signup API', () => {
request: supertest(app),
stores,
perms,
destroy: () => {
services.clientInstanceService.destroy();
services.publicSignupTokenService.destroy();
},
};
}
let stores;
let request;
let destroy;
const user = {
username: 'some-username',
@ -55,12 +50,8 @@ describe('Public Signup API', () => {
const setup = await getSetup();
stores = setup.stores;
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
const expireAt = (addDays: number = 7): Date => {
const now = new Date();
now.setDate(now.getDate() + addDays);

View File

@ -5,8 +5,6 @@ import permissions from '../../../test/fixtures/permissions';
import getApp from '../../app';
import { createServices } from '../../services';
let destroy;
async function getSetup() {
const randomBase = `/random${Math.round(Math.random() * 1000)}`;
const perms = permissions();
@ -18,10 +16,6 @@ async function getSetup() {
const services = createServices(stores, config);
const app = await getApp(config, stores, services);
destroy = () => {
services.clientInstanceService.destroy();
};
return {
base: randomBase,
strategyStore: stores.strategyStore,
@ -30,10 +24,6 @@ async function getSetup() {
};
}
afterEach(() => {
destroy();
});
test('add version numbers for /strategies', async () => {
const { request, base } = await getSetup();
return request

View File

@ -21,26 +21,18 @@ async function getSetup() {
perms,
tagStore: stores.tagStore,
request: supertest(app),
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
let base;
let tagStore;
let request;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
base = setup.base;
tagStore = setup.tagStore;
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
test('should get empty getTags via admin', () => {

View File

@ -19,5 +19,4 @@ test('should enable prometheus', async () => {
.get('/internal-backstage/prometheus')
.expect('Content-Type', /text/)
.expect(200);
services.clientInstanceService.destroy();
});

View File

@ -19,10 +19,7 @@ async function getSetup(opts?: IUnleashOptions) {
request: supertest(app),
stores: db.stores,
services,
destroy: async () => {
services.clientInstanceService.destroy();
await db.destroy();
},
destroy: db.destroy,
};
}
@ -31,7 +28,7 @@ let stores: IUnleashStores;
let services: IUnleashServices;
let destroy;
beforeEach(async () => {
beforeAll(async () => {
const setup = await getSetup();
request = setup.request;
stores = setup.stores;
@ -39,10 +36,14 @@ beforeEach(async () => {
services = setup.services;
});
afterEach(() => {
afterAll(() => {
destroy();
});
afterEach(async () => {
await stores.featureToggleStore.deleteAll();
});
test('should validate client metrics', () => {
return request
.post('/api/client/metrics')

View File

@ -14,20 +14,14 @@ async function getSetup() {
return {
request: supertest(app),
stores,
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
let request;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
getLogger.setMuteError(false);
});

View File

@ -15,21 +15,15 @@ async function getSetup() {
return {
request: supertest(app),
stores,
destroy: () => {
services.clientInstanceService.destroy();
},
};
}
let request;
let destroy;
beforeEach(async () => {
const setup = await getSetup();
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
getLogger.setMuteError(false);
});

View File

@ -39,16 +39,11 @@ describe('Public Signup API', () => {
request: supertest(app),
stores,
perms,
destroy: () => {
services.clientInstanceService.destroy();
services.publicSignupTokenService.destroy();
},
};
}
let stores;
let request;
let destroy;
const user = {
username: 'some-username',
@ -61,12 +56,8 @@ describe('Public Signup API', () => {
const setup = await getSetup();
stores = setup.stores;
request = setup.request;
destroy = setup.destroy;
});
afterEach(() => {
destroy();
});
const expireAt = (addDays: number = 7): Date => {
const now = new Date();
now.setDate(now.getDate() + addDays);

View File

@ -58,9 +58,6 @@ async function createApp(
}
services.schedulerService.stop();
metricsMonitor.stopMonitoring();
stores.clientInstanceStore.destroy();
services.clientMetricsServiceV2.destroy();
services.proxyService.destroy();
services.addonService.destroy();
await db.destroy();
};

View File

@ -5,45 +5,11 @@ import FakeEventStore from '../../../test/fixtures/fake-event-store';
import { createTestConfig } from '../../../test/config/test-config';
import { FakePrivateProjectChecker } from '../../features/private-project/fakePrivateProjectChecker';
/**
* A utility to wait for any pending promises in the test subject code.
* For instance, if the test needs to wait for a timeout/interval handler,
* and that handler does something async, advancing the timers is not enough:
* We have to explicitly wait for the second promise.
* For more info, see https://stackoverflow.com/a/51045733/2868829
*
* Usage in test code after advancing timers, but before making assertions:
*
* test('hello', async () => {
* jest.useFakeTimers('modern');
*
* // Schedule a timeout with a callback that does something async
* // before calling our spy
* const spy = jest.fn();
* setTimeout(async () => {
* await Promise.resolve();
* spy();
* }, 1000);
*
* expect(spy).not.toHaveBeenCalled();
*
* jest.advanceTimersByTime(1500);
* await flushPromises(); // this is required to make it work!
*
* expect(spy).toHaveBeenCalledTimes(1);
*
* jest.useRealTimers();
* });
*/
function flushPromises() {
return Promise.resolve(setImmediate);
}
let config;
beforeAll(() => {
config = createTestConfig({});
});
test('Multiple registrations of same appname and instanceid within same time period should only cause one registration', async () => {
jest.useFakeTimers();
const appStoreSpy = jest.fn();
const bulkSpy = jest.fn();
const clientApplicationsStore: any = {
@ -75,8 +41,8 @@ test('Multiple registrations of same appname and instanceid within same time per
await clientMetrics.registerClient(client1, '127.0.0.1');
await clientMetrics.registerClient(client1, '127.0.0.1');
await clientMetrics.registerClient(client1, '127.0.0.1');
jest.advanceTimersByTime(7000);
await flushPromises();
await clientMetrics.bulkAdd(); // in prod called by a SchedulerService
expect(appStoreSpy).toHaveBeenCalledTimes(1);
expect(bulkSpy).toHaveBeenCalledTimes(1);
@ -93,7 +59,6 @@ test('Multiple registrations of same appname and instanceid within same time per
});
test('Multiple unique clients causes multiple registrations', async () => {
jest.useFakeTimers();
const appStoreSpy = jest.fn();
const bulkSpy = jest.fn();
const clientApplicationsStore: any = {
@ -136,16 +101,14 @@ test('Multiple unique clients causes multiple registrations', async () => {
await clientMetrics.registerClient(client2, '127.0.0.1');
await clientMetrics.registerClient(client2, '127.0.0.1');
jest.advanceTimersByTime(7000);
await flushPromises();
await clientMetrics.bulkAdd(); // in prod called by a SchedulerService
const registrations = appStoreSpy.mock.calls[0][0];
expect(registrations.length).toBe(2);
jest.useRealTimers();
});
test('Same client registered outside of dedup interval will be registered twice', async () => {
jest.useFakeTimers();
const appStoreSpy = jest.fn();
const bulkSpy = jest.fn();
const clientApplicationsStore: any = {
@ -155,8 +118,6 @@ test('Same client registered outside of dedup interval will be registered twice'
bulkUpsert: bulkSpy,
};
const bulkInterval = secondsToMilliseconds(2);
const clientMetrics = new ClientInstanceService(
{
clientMetricsStoreV2: null,
@ -168,7 +129,6 @@ test('Same client registered outside of dedup interval will be registered twice'
},
config,
new FakePrivateProjectChecker(),
bulkInterval,
);
const client1 = {
appName: 'test_app',
@ -181,14 +141,13 @@ test('Same client registered outside of dedup interval will be registered twice'
await clientMetrics.registerClient(client1, '127.0.0.1');
await clientMetrics.registerClient(client1, '127.0.0.1');
jest.advanceTimersByTime(3000);
await clientMetrics.bulkAdd(); // in prod called by a SchedulerService
await clientMetrics.registerClient(client1, '127.0.0.1');
await clientMetrics.registerClient(client1, '127.0.0.1');
await clientMetrics.registerClient(client1, '127.0.0.1');
jest.advanceTimersByTime(3000);
await flushPromises();
await clientMetrics.bulkAdd(); // in prod called by a SchedulerService
expect(appStoreSpy).toHaveBeenCalledTimes(2);
expect(bulkSpy).toHaveBeenCalledTimes(2);
@ -198,11 +157,9 @@ test('Same client registered outside of dedup interval will be registered twice'
expect(firstRegistrations.appName).toBe(secondRegistrations.appName);
expect(firstRegistrations.instanceId).toBe(secondRegistrations.instanceId);
jest.useRealTimers();
});
test('No registrations during a time period will not call stores', async () => {
jest.useFakeTimers();
const appStoreSpy = jest.fn();
const bulkSpy = jest.fn();
const clientApplicationsStore: any = {
@ -211,7 +168,7 @@ test('No registrations during a time period will not call stores', async () => {
const clientInstanceStore: any = {
bulkUpsert: bulkSpy,
};
new ClientInstanceService(
const clientMetrics = new ClientInstanceService(
{
clientMetricsStoreV2: null,
strategyStore: null,
@ -223,8 +180,9 @@ test('No registrations during a time period will not call stores', async () => {
config,
new FakePrivateProjectChecker(),
);
jest.advanceTimersByTime(6000);
await clientMetrics.bulkAdd(); // in prod called by a SchedulerService
expect(appStoreSpy).toHaveBeenCalledTimes(0);
expect(bulkSpy).toHaveBeenCalledTimes(0);
jest.useRealTimers();
});

View File

@ -29,8 +29,6 @@ export default class ClientInstanceService {
seenClients: Record<string, IClientApp> = {};
private timers: NodeJS.Timeout[] = [];
private clientMetricsStoreV2: IClientMetricsStoreV2;
private strategyStore: IStrategyStore;
@ -47,10 +45,6 @@ export default class ClientInstanceService {
private flagResolver: IFlagResolver;
private bulkInterval: number;
private announcementInterval: number;
constructor(
{
clientMetricsStoreV2,
@ -73,8 +67,6 @@ export default class ClientInstanceService {
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
privateProjectChecker: IPrivateProjectChecker,
bulkInterval = secondsToMilliseconds(5),
announcementInterval = minutesToMilliseconds(5),
) {
this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.strategyStore = strategyStore;
@ -87,18 +79,6 @@ export default class ClientInstanceService {
this.logger = getLogger(
'/services/client-metrics/client-instance-service.ts',
);
this.bulkInterval = bulkInterval;
this.announcementInterval = announcementInterval;
this.timers.push(
setInterval(() => this.bulkAdd(), this.bulkInterval).unref(),
);
this.timers.push(
setInterval(
() => this.announceUnannounced(),
this.announcementInterval,
).unref(),
);
}
public async registerInstance(
@ -248,8 +228,4 @@ export default class ClientInstanceService {
async removeInstancesOlderThanTwoDays(): Promise<void> {
return this.clientInstanceStore.removeInstancesOlderThanTwoDays();
}
destroy(): void {
this.timers.forEach(clearInterval);
}
}

View File

@ -11,8 +11,6 @@ export type LastSeenInput = {
};
export class LastSeenService {
private timers: NodeJS.Timeout[] = [];
private lastSeenToggles: Map<String, LastSeenInput> = new Map();
private logger: Logger;
@ -79,8 +77,4 @@ export class LastSeenService {
async cleanLastSeen() {
await this.lastSeenStore.cleanLastSeen();
}
destroy(): void {
this.timers.forEach(clearInterval);
}
}

View File

@ -25,8 +25,6 @@ import { nameSchema } from '../../schema/feature-schema';
export default class ClientMetricsServiceV2 {
private config: IUnleashConfig;
private timers: NodeJS.Timeout[] = [];
private unsavedMetrics: IClientMetricsEnv[] = [];
private clientMetricsStoreV2: IClientMetricsStoreV2;
@ -41,7 +39,6 @@ export default class ClientMetricsServiceV2 {
{ clientMetricsStoreV2 }: Pick<IUnleashStores, 'clientMetricsStoreV2'>,
config: IUnleashConfig,
lastSeenService: LastSeenService,
bulkInterval = secondsToMilliseconds(5),
) {
this.clientMetricsStoreV2 = clientMetricsStoreV2;
this.lastSeenService = lastSeenService;
@ -50,18 +47,10 @@ export default class ClientMetricsServiceV2 {
'/services/client-metrics/client-metrics-service-v2.ts',
);
this.flagResolver = config.flagResolver;
}
this.timers.push(
setInterval(() => {
this.bulkAdd().catch(console.error);
}, bulkInterval).unref(),
);
this.timers.push(
setInterval(() => {
this.clientMetricsStoreV2.clearMetrics(48).catch(console.error);
}, hoursToMilliseconds(12)).unref(),
);
async clearMetrics(hoursAgo: number) {
return this.clientMetricsStoreV2.clearMetrics(hoursAgo);
}
async filterValidToggleNames(toggleNames: string[]): Promise<string[]> {
@ -245,9 +234,4 @@ export default class ClientMetricsServiceV2 {
}
return 'default';
}
destroy(): void {
this.timers.forEach(clearInterval);
this.lastSeenService.destroy();
}
}

View File

@ -118,6 +118,8 @@ export const scheduleServices = async (
featureToggleService,
versionService,
lastSeenService,
proxyService,
clientMetricsServiceV2,
} = services;
if (await maintenanceService.isMaintenanceMode()) {
@ -164,6 +166,18 @@ export const scheduleServices = async (
'removeInstancesOlderThanTwoDays',
);
schedulerService.schedule(
clientInstanceService.bulkAdd.bind(clientInstanceService),
secondsToMilliseconds(5),
'bulkAddInstances',
);
schedulerService.schedule(
clientInstanceService.announceUnannounced.bind(clientInstanceService),
minutesToMilliseconds(5),
'announceUnannounced',
);
schedulerService.schedule(
projectService.statusJob.bind(projectService),
hoursToMilliseconds(24),
@ -205,6 +219,28 @@ export const scheduleServices = async (
hoursToMilliseconds(48),
'checkLatestVersion',
);
schedulerService.schedule(
proxyService.fetchFrontendSettings.bind(proxyService),
minutesToMilliseconds(2),
'fetchFrontendSettings',
);
schedulerService.schedule(
() => {
clientMetricsServiceV2.bulkAdd().catch(console.error);
},
secondsToMilliseconds(5),
'bulkAddMetrics',
);
schedulerService.schedule(
() => {
clientMetricsServiceV2.clearMetrics(48).catch(console.error);
},
hoursToMilliseconds(12),
'clearMetrics',
);
};
export const createServices = (

View File

@ -53,18 +53,11 @@ export class ProxyService {
private cachedFrontendSettings?: FrontendSettings;
private timer: NodeJS.Timeout | null;
constructor(config: Config, stores: Stores, services: Services) {
this.config = config;
this.logger = config.getLogger('services/proxy-service.ts');
this.stores = stores;
this.services = services;
this.timer = setInterval(
() => this.fetchFrontendSettings(),
minutesToMilliseconds(2),
).unref();
}
async getProxyFeatures(
@ -181,7 +174,7 @@ export class ProxyService {
);
}
private async fetchFrontendSettings(): Promise<FrontendSettings> {
async fetchFrontendSettings(): Promise<FrontendSettings> {
try {
this.cachedFrontendSettings =
await this.services.settingService.get(frontendSettingsKey, {
@ -201,11 +194,4 @@ export class ProxyService {
}
return this.fetchFrontendSettings();
}
destroy(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}

View File

@ -30,8 +30,6 @@ export class PublicSignupTokenService {
private logger: Logger;
private timer: NodeJS.Timeout;
private readonly unleashBase: string;
constructor(
@ -146,9 +144,4 @@ export class PublicSignupTokenService {
private getMinimumDate(date1: Date, date2: Date): Date {
return date1 < date2 ? date1 : date2;
}
destroy(): void {
clearInterval(this.timer);
this.timer = null;
}
}

View File

@ -23,7 +23,6 @@ export type IFlagKey =
| 'filterInvalidClientMetrics'
| 'lastSeenByEnvironment'
| 'customRootRolesKillSwitch'
| 'multipleRoles'
| 'featureNamingPattern'
| 'doraMetrics'
| 'variantTypeNumber'
@ -117,10 +116,6 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES_KILL_SWITCH,
false,
),
multipleRoles: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_MULTIPLE_ROLES,
false,
),
featureNamingPattern: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FEATURE_NAMING_PATTERN,
false,

View File

@ -117,9 +117,7 @@ export default async function init(
await setupDatabase(stores);
},
destroy: async () => {
const { clientInstanceStore } = stores;
return new Promise<void>((resolve, reject) => {
clientInstanceStore.destroy();
testDb.destroy((error) => (error ? reject(error) : resolve()));
});
},

View File

@ -1,9 +1,12 @@
import { getDbConfig } from './helpers/database-config';
import { createTestConfig } from '../config/test-config';
import { getInstance } from 'db-migrate';
import { log } from 'db-migrate-shared';
import { Client } from 'pg';
import { IDBOption } from 'lib/types';
log.setLogLevel('error');
async function initSchema(db: IDBOption): Promise<void> {
const client = new Client(db);
await client.connect();
@ -30,6 +33,8 @@ test('Up & down migrations work', async () => {
connectionTimeoutMillis: 2000,
};
// disable Intellij/WebStorm from setting verbose CLI argument to db-migrator
process.argv = process.argv.filter((it) => !it.includes('--verbose'));
const dbm = getInstance(true, {
cwd: `${__dirname}/../../`, // relative to src/test/e2e
config: { e2e },

View File

@ -12,7 +12,7 @@ const { APPLICATION_CREATED } = require('../../../lib/types/events');
let stores;
let db;
let clientInstanceService;
let clientInstanceService: ClientInstanceService;
let config: IUnleashConfig;
beforeAll(async () => {
db = await dbInit('client_metrics_service_serial', getLogger);
@ -25,13 +25,10 @@ beforeAll(async () => {
stores,
config,
new FakePrivateProjectChecker(),
bulkInterval,
announcementInterval,
);
});
afterAll(async () => {
await clientInstanceService.destroy();
await db.destroy();
});
test('Apps registered should be announced', async () => {
@ -58,11 +55,11 @@ test('Apps registered should be announced', async () => {
};
await clientInstanceService.registerClient(clientRegistration, '127.0.0.1');
await clientInstanceService.registerClient(differentClient, '127.0.0.1');
await new Promise((res) => setTimeout(res, 1200));
await clientInstanceService.bulkAdd(); // in prod called by a SchedulerService
const first = await stores.clientApplicationsStore.getUnannounced();
expect(first.length).toBe(2);
await clientInstanceService.registerClient(clientRegistration, '127.0.0.1');
await new Promise((res) => setTimeout(res, secondsToMilliseconds(2)));
await clientInstanceService.announceUnannounced(); // in prod called by a SchedulerService
const second = await stores.clientApplicationsStore.getUnannounced();
expect(second.length).toBe(0);
const events = await stores.eventStore.getEvents();

View File

@ -56,8 +56,6 @@ test('Should update last seen for known toggles', async () => {
const t1 = await stores.featureToggleStore.get('ta1');
expect(t1.lastSeenAt.getTime()).toBeGreaterThan(time);
service.destroy();
});
test('Should not update last seen toggles with 0 metrics', async () => {
@ -102,8 +100,6 @@ test('Should not update last seen toggles with 0 metrics', async () => {
expect(t2.lastSeenAt).toBeNull();
expect(t1.lastSeenAt.getTime()).toBeGreaterThanOrEqual(time);
service.destroy();
});
test('Should not update anything for 0 toggles', async () => {
@ -144,6 +140,4 @@ test('Should not update anything for 0 toggles', async () => {
const count = await service.store();
expect(count).toBe(0);
service.destroy();
});