mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-10 17:53:36 +02:00
Merge branch 'main' into feat/api-handling-of-cr-segments
This commit is contained in:
commit
ae20bcbae0
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Button, Divider, Typography, styled } from '@mui/material';
|
||||
import { PermMedia, Send } from '@mui/icons-material';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import { CustomEvents, usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
@ -61,12 +61,21 @@ const StyledLink = styled('a')(({ theme }) => ({
|
||||
textDecoration: 'none',
|
||||
}));
|
||||
|
||||
export const ProjectDoraFeedback = () => {
|
||||
interface IExperimentalFeedbackProps {
|
||||
trackerKey: string;
|
||||
eventKey: CustomEvents;
|
||||
description: string;
|
||||
sketchURL: string;
|
||||
}
|
||||
|
||||
export const ExperimentalFeedback: React.FC<IExperimentalFeedbackProps> = ({
|
||||
trackerKey,
|
||||
eventKey,
|
||||
description,
|
||||
sketchURL,
|
||||
}) => {
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
const { value, setValue } = createLocalStorage(
|
||||
`project:metrics:plausible`,
|
||||
{ sent: false },
|
||||
);
|
||||
const { value, setValue } = createLocalStorage(trackerKey, { sent: false });
|
||||
const [metrics, setMetrics] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
@ -75,7 +84,7 @@ export const ProjectDoraFeedback = () => {
|
||||
|
||||
const onBtnClick = (type: string) => {
|
||||
try {
|
||||
trackEvent('project-metrics', {
|
||||
trackEvent(eventKey, {
|
||||
props: {
|
||||
eventType: type,
|
||||
},
|
||||
@ -91,7 +100,7 @@ export const ProjectDoraFeedback = () => {
|
||||
|
||||
const recipientEmail = 'ux@getunleash.io';
|
||||
const emailSubject = "I'd like to get involved";
|
||||
const emailBody = `Hello Unleash,\n\nI just saw the new metrics page you are experimenting with in Unleash. I'd like to be involved in user tests and give my feedback on this feature.\n\nRegards,\n`;
|
||||
const emailBody = `Hello Unleash,\n\nI just saw your ${eventKey} experiment. I'd like to be involved in user tests and give my feedback on this feature.\n\nRegards,\n`;
|
||||
|
||||
const mailtoURL = `mailto:${recipientEmail}?subject=${encodeURIComponent(
|
||||
emailSubject,
|
||||
@ -102,31 +111,10 @@ export const ProjectDoraFeedback = () => {
|
||||
<StyledHeader variant='h1'>
|
||||
We are trying something experimental!
|
||||
</StyledHeader>
|
||||
<Typography>
|
||||
We are considering adding project metrics to see how a project
|
||||
performs. As a first step, we have added a{' '}
|
||||
<i>lead time for changes</i> indicator that is calculated per
|
||||
feature toggle based on the creation of the feature toggle and
|
||||
when it was first turned on in an environment of type
|
||||
production.
|
||||
</Typography>
|
||||
<Typography>{description}</Typography>
|
||||
|
||||
<br />
|
||||
|
||||
<Typography>
|
||||
DORA is a method for measuring the performance of your DevOps
|
||||
teams. It measures four different metrics. You can read Google's
|
||||
blog post about{' '}
|
||||
<a
|
||||
href='https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
DORA metrics
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Typography>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={!metrics.sent}
|
||||
show={
|
||||
@ -170,7 +158,7 @@ export const ProjectDoraFeedback = () => {
|
||||
<PermMedia />
|
||||
</StyledIconWrapper>
|
||||
<StyledLink
|
||||
href='https://app.mural.co/t/unleash2757/m/unleash2757/1694006366166/fae4aa4f796de214bdb3ae2d5ce9de934b68fdfb?sender=u777a1f5633477c329eae3448'
|
||||
href={sketchURL}
|
||||
target='_blank'
|
||||
rel='noopener noreferer'
|
||||
>
|
@ -23,7 +23,6 @@ import React from 'react';
|
||||
import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
|
||||
import { FeatureNamingType } from 'interfaces/project';
|
||||
import { FeatureNamingPatternInfo } from '../FeatureNamingPatternInfo/FeatureNamingPatternInfo';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
interface IFeatureToggleForm {
|
||||
type: string;
|
||||
@ -122,15 +121,12 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
||||
const navigate = useNavigate();
|
||||
const { permissions } = useAuthPermissions();
|
||||
const editable = mode !== 'Edit';
|
||||
const featureNamingPatternEnabled = useUiFlag('featureNamingPattern');
|
||||
|
||||
const renderToggleDescription = () => {
|
||||
return featureTypes.find((toggle) => toggle.id === type)?.description;
|
||||
};
|
||||
|
||||
const displayFeatureNamingInfo = Boolean(
|
||||
featureNamingPatternEnabled && featureNaming?.pattern,
|
||||
);
|
||||
const displayFeatureNamingInfo = Boolean(featureNaming?.pattern);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (featureNaming?.pattern && validateToggleName && name) {
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { VisibilityOff } from '@mui/icons-material';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
|
||||
export const StyledVisibilityIcon = styled(VisibilityOff)(({ theme }) => ({
|
||||
color: theme.palette.action.disabled,
|
||||
}));
|
||||
|
||||
export const HiddenProjectIconWithTooltip = () => (
|
||||
<HtmlTooltip
|
||||
title={`This projects collaboration mode is set to private. The project and associated feature
|
||||
toggles can only be seen by you and the members of the project`}
|
||||
arrow
|
||||
>
|
||||
<StyledVisibilityIcon />
|
||||
</HtmlTooltip>
|
||||
);
|
@ -41,6 +41,7 @@ import { Badge } from 'component/common/Badge/Badge';
|
||||
import { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics';
|
||||
import { UiFlags } from 'interfaces/uiConfig';
|
||||
import { ExperimentalProjectFeatures } from './ExperimentalProjectFeatures/ExperimentalProjectFeatures';
|
||||
import { HiddenProjectIconWithTooltip } from './HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip';
|
||||
|
||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
@ -102,8 +103,7 @@ export const Project = () => {
|
||||
title: 'Metrics',
|
||||
path: `${basePath}/metrics`,
|
||||
name: 'dora',
|
||||
flag: 'doraMetrics',
|
||||
new: true,
|
||||
isEnterprise: true,
|
||||
},
|
||||
{
|
||||
title: 'Event log',
|
||||
@ -190,6 +190,10 @@ export const Project = () => {
|
||||
isFavorite={project?.favorite}
|
||||
/>
|
||||
<StyledProjectTitle>
|
||||
<ConditionallyRender
|
||||
condition={project?.mode === 'private'}
|
||||
show={<HiddenProjectIconWithTooltip />}
|
||||
/>
|
||||
<StyledName data-loading>
|
||||
{projectName}
|
||||
</StyledName>
|
||||
|
@ -15,7 +15,6 @@ import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { ProjectDoraFeedback } from './ProjectDoraFeedback/ProjectDoraFeedback';
|
||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||
import theme from 'themes/theme';
|
||||
|
||||
@ -194,7 +193,6 @@ export const ProjectDoraMetrics = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectDoraFeedback />
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
|
@ -23,13 +23,13 @@ export const CollaborationModeTooltip: FC = () => {
|
||||
<Box>
|
||||
<StyledTitle>open: </StyledTitle>
|
||||
<StyledDescription>
|
||||
everyone can submit change requests
|
||||
Everyone can submit change requests
|
||||
</StyledDescription>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<StyledTitle>protected: </StyledTitle>
|
||||
<StyledDescription>
|
||||
only admins and project members can submit change
|
||||
Only admins and project members can submit change
|
||||
requests
|
||||
</StyledDescription>
|
||||
</Box>
|
||||
@ -39,8 +39,9 @@ export const CollaborationModeTooltip: FC = () => {
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<StyledTitle>private: </StyledTitle>
|
||||
<StyledDescription>
|
||||
only projects members can and access see the
|
||||
project
|
||||
Only admins, editors and project members can
|
||||
see and access the project and associated
|
||||
feature toggles
|
||||
</StyledDescription>
|
||||
</Box>
|
||||
}
|
||||
|
@ -136,7 +136,6 @@ const ProjectEnterpriseSettingsForm: React.FC<IProjectEnterpriseSettingsForm> =
|
||||
clearErrors,
|
||||
}) => {
|
||||
const privateProjects = useUiFlag('privateProjects');
|
||||
const shouldShowFlagNaming = useUiFlag('featureNamingPattern');
|
||||
|
||||
const { setPreviousPattern, trackPattern } =
|
||||
useFeatureNamePatternTracking();
|
||||
@ -253,115 +252,104 @@ const ProjectEnterpriseSettingsForm: React.FC<IProjectEnterpriseSettingsForm> =
|
||||
options={projectModeOptions}
|
||||
/>
|
||||
</>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(shouldShowFlagNaming)}
|
||||
show={
|
||||
<StyledFieldset>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 1,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<legend>Feature flag naming pattern?</legend>
|
||||
<FeatureFlagNamingTooltip />
|
||||
</Box>
|
||||
<StyledSubtitle>
|
||||
<StyledPatternNamingExplanation id='pattern-naming-description'>
|
||||
<p>
|
||||
Define a{' '}
|
||||
<a
|
||||
href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
JavaScript RegEx
|
||||
</a>{' '}
|
||||
used to enforce feature flag names
|
||||
within this project. The regex will be
|
||||
surrounded by a leading <code>^</code>{' '}
|
||||
and a trailing <code>$</code>.
|
||||
</p>
|
||||
<p>
|
||||
Leave it empty if you don’t want to add
|
||||
a naming pattern.
|
||||
</p>
|
||||
</StyledPatternNamingExplanation>
|
||||
</StyledSubtitle>
|
||||
<StyledFlagNamingContainer>
|
||||
<StyledInput
|
||||
label={'Naming Pattern'}
|
||||
name='feature flag naming pattern'
|
||||
aria-describedby='pattern-naming-description'
|
||||
placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+'
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
^
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
$
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
type={'text'}
|
||||
value={featureNamingPattern || ''}
|
||||
error={Boolean(errors.featureNamingPattern)}
|
||||
errorText={errors.featureNamingPattern}
|
||||
onChange={(e) =>
|
||||
onSetFeatureNamingPattern(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StyledSubtitle>
|
||||
<p id='pattern-additional-description'>
|
||||
The example and description will be
|
||||
shown to users when they create a new
|
||||
feature flag in this project.
|
||||
</p>
|
||||
</StyledSubtitle>
|
||||
<StyledFieldset>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 1,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<legend>Feature flag naming pattern?</legend>
|
||||
<FeatureFlagNamingTooltip />
|
||||
</Box>
|
||||
<StyledSubtitle>
|
||||
<StyledPatternNamingExplanation id='pattern-naming-description'>
|
||||
<p>
|
||||
Define a{' '}
|
||||
<a
|
||||
href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
JavaScript RegEx
|
||||
</a>{' '}
|
||||
used to enforce feature flag names within this
|
||||
project. The regex will be surrounded by a
|
||||
leading <code>^</code> and a trailing{' '}
|
||||
<code>$</code>.
|
||||
</p>
|
||||
<p>
|
||||
Leave it empty if you don’t want to add a naming
|
||||
pattern.
|
||||
</p>
|
||||
</StyledPatternNamingExplanation>
|
||||
</StyledSubtitle>
|
||||
<StyledFlagNamingContainer>
|
||||
<StyledInput
|
||||
label={'Naming Pattern'}
|
||||
name='feature flag naming pattern'
|
||||
aria-describedby='pattern-naming-description'
|
||||
placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+'
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
^
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
$
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
type={'text'}
|
||||
value={featureNamingPattern || ''}
|
||||
error={Boolean(errors.featureNamingPattern)}
|
||||
errorText={errors.featureNamingPattern}
|
||||
onChange={(e) =>
|
||||
onSetFeatureNamingPattern(e.target.value)
|
||||
}
|
||||
/>
|
||||
<StyledSubtitle>
|
||||
<p id='pattern-additional-description'>
|
||||
The example and description will be shown to
|
||||
users when they create a new feature flag in
|
||||
this project.
|
||||
</p>
|
||||
</StyledSubtitle>
|
||||
|
||||
<StyledInput
|
||||
label={'Naming Example'}
|
||||
name='feature flag naming example'
|
||||
type={'text'}
|
||||
aria-describedby='pattern-additional-description'
|
||||
value={featureNamingExample || ''}
|
||||
placeholder='dx.feature1.1-135'
|
||||
error={Boolean(errors.namingExample)}
|
||||
errorText={errors.namingExample}
|
||||
onChange={(e) =>
|
||||
onSetFeatureNamingExample(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StyledTextField
|
||||
label={'Naming pattern description'}
|
||||
name='feature flag naming description'
|
||||
type={'text'}
|
||||
aria-describedby='pattern-additional-description'
|
||||
placeholder={`<project>.<featureName>.<ticket>
|
||||
<StyledInput
|
||||
label={'Naming Example'}
|
||||
name='feature flag naming example'
|
||||
type={'text'}
|
||||
aria-describedby='pattern-additional-description'
|
||||
value={featureNamingExample || ''}
|
||||
placeholder='dx.feature1.1-135'
|
||||
error={Boolean(errors.namingExample)}
|
||||
errorText={errors.namingExample}
|
||||
onChange={(e) =>
|
||||
onSetFeatureNamingExample(e.target.value)
|
||||
}
|
||||
/>
|
||||
<StyledTextField
|
||||
label={'Naming pattern description'}
|
||||
name='feature flag naming description'
|
||||
type={'text'}
|
||||
aria-describedby='pattern-additional-description'
|
||||
placeholder={`<project>.<featureName>.<ticket>
|
||||
|
||||
The flag name should contain the project name, the feature name, and the ticket number, each separated by a dot.`}
|
||||
multiline
|
||||
minRows={5}
|
||||
value={featureNamingDescription || ''}
|
||||
onChange={(e) =>
|
||||
onSetFeatureNamingDescription(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</StyledFlagNamingContainer>
|
||||
</StyledFieldset>
|
||||
}
|
||||
/>
|
||||
multiline
|
||||
minRows={5}
|
||||
value={featureNamingDescription || ''}
|
||||
onChange={(e) =>
|
||||
onSetFeatureNamingDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</StyledFlagNamingContainer>
|
||||
</StyledFieldset>
|
||||
<StyledButtonContainer>{children}</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
);
|
||||
|
@ -75,3 +75,8 @@ export const StyledParagraphInfo = styled('p')(({ theme }) => ({
|
||||
color: theme.palette.primary.dark,
|
||||
fontWeight: 'bold',
|
||||
}));
|
||||
|
||||
export const StyledIconBox = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}));
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Menu, MenuItem } from '@mui/material';
|
||||
import { Menu, MenuItem, Tooltip, Box } from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import React, { SyntheticEvent, useContext, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -26,8 +26,10 @@ import {
|
||||
StyledDivInfo,
|
||||
StyledDivInfoContainer,
|
||||
StyledParagraphInfo,
|
||||
StyledIconBox,
|
||||
} from './ProjectCard.styles';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { HiddenProjectIconWithTooltip } from '../Project/HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip';
|
||||
|
||||
interface IProjectCardProps {
|
||||
name: string;
|
||||
@ -37,6 +39,7 @@ interface IProjectCardProps {
|
||||
id: string;
|
||||
onHover: () => void;
|
||||
isFavorite?: boolean;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export const ProjectCard = ({
|
||||
@ -46,6 +49,7 @@ export const ProjectCard = ({
|
||||
memberCount,
|
||||
onHover,
|
||||
id,
|
||||
mode,
|
||||
isFavorite = false,
|
||||
}: IProjectCardProps) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
@ -127,9 +131,13 @@ export const ProjectCard = ({
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</StyledDivHeader>
|
||||
<div data-loading>
|
||||
<StyledProjectIcon />
|
||||
</div>
|
||||
<StyledIconBox data-loading>
|
||||
<ConditionallyRender
|
||||
condition={mode === 'private'}
|
||||
show={<HiddenProjectIconWithTooltip />}
|
||||
elseShow={<StyledProjectIcon />}
|
||||
/>
|
||||
</StyledIconBox>
|
||||
<StyledDivInfo>
|
||||
<StyledDivInfoContainer>
|
||||
<StyledParagraphInfo data-loading>
|
||||
|
@ -245,6 +245,7 @@ export const ProjectListNew = () => {
|
||||
key={project.id}
|
||||
name={project.name}
|
||||
id={project.id}
|
||||
mode={project.mode}
|
||||
memberCount={2}
|
||||
health={95}
|
||||
featureCount={4}
|
||||
@ -263,6 +264,7 @@ export const ProjectListNew = () => {
|
||||
handleHover(project.id)
|
||||
}
|
||||
name={project.name}
|
||||
mode={project.mode}
|
||||
memberCount={
|
||||
project.memberCount ?? 0
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ const loadingData = [
|
||||
featureCount: 4,
|
||||
createdAt: '',
|
||||
description: '',
|
||||
mode: '',
|
||||
},
|
||||
{
|
||||
id: 'loading2',
|
||||
@ -16,6 +17,7 @@ const loadingData = [
|
||||
featureCount: 4,
|
||||
createdAt: '',
|
||||
description: '',
|
||||
mode: '',
|
||||
},
|
||||
{
|
||||
id: 'loading3',
|
||||
@ -25,6 +27,7 @@ const loadingData = [
|
||||
featureCount: 4,
|
||||
createdAt: '',
|
||||
description: '',
|
||||
mode: '',
|
||||
},
|
||||
{
|
||||
id: 'loading4',
|
||||
@ -34,6 +37,7 @@ const loadingData = [
|
||||
featureCount: 4,
|
||||
createdAt: '',
|
||||
description: '',
|
||||
mode: '',
|
||||
},
|
||||
];
|
||||
|
||||
|
291
frontend/src/hooks/useTableState.test.ts
Normal file
291
frontend/src/hooks/useTableState.test.ts
Normal file
@ -0,0 +1,291 @@
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useTableState } from './useTableState';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { act } from 'react-test-renderer';
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
|
||||
}));
|
||||
vi.mock('../utils/createLocalStorage', () => ({
|
||||
createLocalStorage: vi.fn(() => ({
|
||||
value: {},
|
||||
setValue: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockStorage = createLocalStorage as Mock;
|
||||
const mockQuery = useSearchParams as Mock;
|
||||
|
||||
describe('useTableState', () => {
|
||||
beforeEach(() => {
|
||||
mockStorage.mockRestore();
|
||||
mockQuery.mockRestore();
|
||||
});
|
||||
|
||||
it('should return default params', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
page: '0';
|
||||
pageSize: '10';
|
||||
}>({ page: '0', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
expect(result.current[0]).toEqual({ page: '0', pageSize: '10' });
|
||||
});
|
||||
|
||||
it('should return params from local storage', () => {
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: 25 },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ pageSize: 25 });
|
||||
});
|
||||
|
||||
it('should return params from url', () => {
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=1'), vi.fn()]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '0' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
});
|
||||
|
||||
it('should use params from url over local storage', () => {
|
||||
mockQuery.mockReturnValue([
|
||||
new URLSearchParams('page=2&pageSize=25'),
|
||||
vi.fn(),
|
||||
]);
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: '10', sortOrder: 'desc' },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '5' }, 'test', [], []),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '2',
|
||||
pageSize: '25',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets local state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2' });
|
||||
});
|
||||
|
||||
it('keeps previous state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '2' });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2', pageSize: '10' });
|
||||
});
|
||||
|
||||
it('removes params from previous state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTableState({ page: '1', pageSize: '10' }, 'test', [], []),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '1' });
|
||||
|
||||
// ensure that there are no keys with undefined values
|
||||
expect(Object.keys(result.current[0])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes params from url', () => {
|
||||
const querySetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=2'), querySetter]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState(
|
||||
{ page: '1', pageSize: '10' },
|
||||
'test',
|
||||
['page', 'pageSize'],
|
||||
[],
|
||||
),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '2', pageSize: '10' });
|
||||
|
||||
act(() => {
|
||||
setParams({ page: '10', pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ page: '10' });
|
||||
|
||||
expect(querySetter).toHaveBeenCalledWith({
|
||||
page: '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes params from local storage', () => {
|
||||
const storageSetter = vi.fn();
|
||||
mockStorage.mockReturnValue({
|
||||
value: { sortBy: 'type' },
|
||||
setValue: storageSetter,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState(
|
||||
{ sortBy: 'createdAt', pageSize: '10' },
|
||||
'test',
|
||||
[],
|
||||
['sortBy', 'pageSize'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toEqual({ sortBy: 'type', pageSize: '10' });
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ pageSize: undefined });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ sortBy: 'type' });
|
||||
|
||||
expect(storageSetter).toHaveBeenCalledWith({
|
||||
sortBy: 'type',
|
||||
});
|
||||
});
|
||||
|
||||
test('saves default parameters if not explicitly provided', (key) => {
|
||||
const querySetter = vi.fn();
|
||||
const storageSetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams(), querySetter]);
|
||||
mockStorage.mockReturnValue({
|
||||
value: {},
|
||||
setValue: storageSetter,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTableState({}, 'test'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]({
|
||||
unspecified: 'test',
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(storageSetter).toHaveBeenCalledTimes(1);
|
||||
expect(storageSetter).toHaveBeenCalledWith({
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
expect(querySetter).toHaveBeenCalledTimes(1);
|
||||
expect(querySetter).toHaveBeenCalledWith({
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't save default params if explicitly specified", () => {
|
||||
const storageSetter = vi.fn();
|
||||
mockStorage.mockReturnValue({
|
||||
value: {},
|
||||
setValue: storageSetter,
|
||||
});
|
||||
const querySetter = vi.fn();
|
||||
mockQuery.mockReturnValue([new URLSearchParams(), querySetter]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
[key: string]: string | string[];
|
||||
}>({}, 'test', ['saveOnlyThisToUrl'], ['page']),
|
||||
);
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({
|
||||
saveOnlyThisToUrl: 'test',
|
||||
page: '2',
|
||||
pageSize: '10',
|
||||
search: 'test',
|
||||
sortBy: 'type',
|
||||
sortOrder: 'favorites',
|
||||
favorites: 'false',
|
||||
columns: ['test', 'id'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(querySetter).toHaveBeenCalledWith({ saveOnlyThisToUrl: 'test' });
|
||||
expect(storageSetter).toHaveBeenCalledWith({ page: '2' });
|
||||
});
|
||||
|
||||
it('can reset state to the default instead of overwriting', () => {
|
||||
mockStorage.mockReturnValue({
|
||||
value: { pageSize: 25 },
|
||||
setValue: vi.fn(),
|
||||
});
|
||||
mockQuery.mockReturnValue([new URLSearchParams('page=4'), vi.fn()]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTableState<{
|
||||
page: string;
|
||||
pageSize?: string;
|
||||
sortBy?: string;
|
||||
}>({ page: '1', pageSize: '10' }, 'test'),
|
||||
);
|
||||
|
||||
const setParams = result.current[1];
|
||||
|
||||
act(() => {
|
||||
setParams({ sortBy: 'type' });
|
||||
});
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '4',
|
||||
pageSize: '10',
|
||||
sortBy: 'type',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setParams({ pageSize: '50' }, true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({
|
||||
page: '1',
|
||||
pageSize: '50',
|
||||
});
|
||||
});
|
||||
});
|
82
frontend/src/hooks/useTableState.ts
Normal file
82
frontend/src/hooks/useTableState.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { createLocalStorage } from '../utils/createLocalStorage';
|
||||
|
||||
const filterObjectKeys = <T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
keys: Array<keyof T>,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(obj).filter(([key]) => keys.includes(key as keyof T)),
|
||||
) as T;
|
||||
|
||||
const defaultStoredKeys = [
|
||||
'pageSize',
|
||||
'search',
|
||||
'sortBy',
|
||||
'sortOrder',
|
||||
'favorites',
|
||||
'columns',
|
||||
];
|
||||
const defaultQueryKeys = [...defaultStoredKeys, 'page'];
|
||||
|
||||
/**
|
||||
* There are 3 sources of params, in order of priority:
|
||||
* 1. local state
|
||||
* 2. search params from the url
|
||||
* 3. stored params in local storage
|
||||
* 4. default parameters
|
||||
*
|
||||
* `queryKeys` will be saved in the url
|
||||
* `storedKeys` will be saved in local storage
|
||||
*
|
||||
* @param defaultParams initial state
|
||||
* @param storageId identifier for the local storage
|
||||
* @param queryKeys array of elements to be saved in the url
|
||||
* @param storageKeys array of elements to be saved in local storage
|
||||
*/
|
||||
export const useTableState = <Params extends Record<string, string | string[]>>(
|
||||
defaultParams: Params,
|
||||
storageId: string,
|
||||
queryKeys?: Array<keyof Params>,
|
||||
storageKeys?: Array<keyof Params>,
|
||||
) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { value: storedParams, setValue: setStoredParams } =
|
||||
createLocalStorage(`${storageId}:tableQuery`, defaultParams);
|
||||
|
||||
const searchQuery = Object.fromEntries(searchParams.entries());
|
||||
const [params, setParams] = useState({
|
||||
...defaultParams,
|
||||
...(Object.keys(searchQuery).length ? {} : storedParams),
|
||||
...searchQuery,
|
||||
} as Params);
|
||||
|
||||
const updateParams = (value: Partial<Params>, reset = false) => {
|
||||
const newState: Params = reset
|
||||
? { ...defaultParams, ...value }
|
||||
: {
|
||||
...params,
|
||||
...value,
|
||||
};
|
||||
|
||||
// remove keys with undefined values
|
||||
Object.keys(newState).forEach((key) => {
|
||||
if (newState[key] === undefined) {
|
||||
delete newState[key];
|
||||
}
|
||||
});
|
||||
|
||||
setParams(newState);
|
||||
setSearchParams(
|
||||
filterObjectKeys(newState, queryKeys || defaultQueryKeys),
|
||||
);
|
||||
setStoredParams(
|
||||
filterObjectKeys(newState, storageKeys || defaultStoredKeys),
|
||||
);
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
return [params, updateParams] as const;
|
||||
};
|
@ -10,6 +10,7 @@ export interface IProjectCard {
|
||||
health: number;
|
||||
description: string;
|
||||
featureCount: number;
|
||||
mode: string;
|
||||
memberCount?: number;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
@ -61,7 +61,6 @@ export type UiFlags = {
|
||||
customRootRolesKillSwitch?: boolean;
|
||||
strategyVariant?: boolean;
|
||||
lastSeenByEnvironment?: boolean;
|
||||
featureNamingPattern?: boolean;
|
||||
doraMetrics?: boolean;
|
||||
variantTypeNumber?: boolean;
|
||||
privateProjects?: boolean;
|
||||
|
@ -81,10 +81,8 @@ exports[`should create default config 1`] = `
|
||||
"disableEnvsOnRevive": false,
|
||||
"disableMetrics": false,
|
||||
"disableNotifications": false,
|
||||
"doraMetrics": false,
|
||||
"embedProxy": true,
|
||||
"embedProxyFrontend": true,
|
||||
"featureNamingPattern": false,
|
||||
"featureSearchAPI": false,
|
||||
"featureSearchFrontend": false,
|
||||
"featuresExportImport": true,
|
||||
|
@ -159,7 +159,6 @@ beforeAll(async () => {
|
||||
experimental: {
|
||||
flags: {
|
||||
featuresExportImport: true,
|
||||
featureNamingPattern: true,
|
||||
dependentFeatures: true,
|
||||
},
|
||||
},
|
||||
|
@ -1168,8 +1168,9 @@ class FeatureToggleService {
|
||||
projectId: string,
|
||||
featureNames: string[],
|
||||
): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
||||
if (this.flagResolver.isEnabled('featureNamingPattern')) {
|
||||
try {
|
||||
const project = await this.projectStore.get(projectId);
|
||||
|
||||
const patternData = project.featureNaming;
|
||||
const namingPattern = patternData?.pattern;
|
||||
|
||||
@ -1183,7 +1184,17 @@ class FeatureToggleService {
|
||||
return { ...result, featureNaming: patternData };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// the project doesn't exist, so there's nothing to
|
||||
// validate against
|
||||
this.logger.info(
|
||||
"Got an error when trying to validate flag naming patterns. It is probably because the target project doesn't exist. Here's the error:",
|
||||
error.message,
|
||||
);
|
||||
|
||||
return { state: 'valid' };
|
||||
}
|
||||
|
||||
return { state: 'valid' };
|
||||
}
|
||||
|
||||
|
@ -709,7 +709,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
const [, envName] = sortBy.split(':');
|
||||
rankingSql += this.db
|
||||
.raw(
|
||||
`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc`,
|
||||
`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`,
|
||||
[envName],
|
||||
)
|
||||
.toString();
|
||||
@ -718,9 +718,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
.raw(`?? ${validatedSortOrder}`, [
|
||||
sortByMapping[sortBy],
|
||||
])
|
||||
.toString()}, features.created_at asc`;
|
||||
.toString()}, features.created_at asc, features.name asc`;
|
||||
} else {
|
||||
rankingSql += `features.created_at ${validatedSortOrder}`;
|
||||
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
|
||||
}
|
||||
|
||||
query
|
||||
|
@ -39,7 +39,7 @@ const irrelevantDate = new Date();
|
||||
beforeAll(async () => {
|
||||
const config = createTestConfig({
|
||||
experimental: {
|
||||
flags: { featureNamingPattern: true, playgroundImprovements: true },
|
||||
flags: { playgroundImprovements: true },
|
||||
},
|
||||
});
|
||||
db = await dbInit(
|
||||
@ -641,6 +641,20 @@ describe('flag name validation', () => {
|
||||
).resolves.toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test("should allow anything if the project doesn't exist", async () => {
|
||||
const projectId = 'project-that-doesnt-exist';
|
||||
const validFeatures = ['testpattern-feature', 'testpattern-feature2'];
|
||||
|
||||
for (const feature of validFeatures) {
|
||||
await expect(
|
||||
service.validateFeatureFlagNameAgainstPattern(
|
||||
feature,
|
||||
projectId,
|
||||
),
|
||||
).resolves.toBeFalsy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('Should return last seen at per environment', async () => {
|
||||
|
153
src/lib/features/scheduler/schedule-services.ts
Normal file
153
src/lib/features/scheduler/schedule-services.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import {
|
||||
hoursToMilliseconds,
|
||||
minutesToMilliseconds,
|
||||
secondsToMilliseconds,
|
||||
} from 'date-fns';
|
||||
import { IUnleashServices } from '../../server-impl';
|
||||
|
||||
/**
|
||||
* Schedules service methods.
|
||||
*
|
||||
* In order to promote runtime control, you should **not use** a flagResolver inside this method. Instead, implement your flag usage inside the scheduled methods themselves.
|
||||
* @param services
|
||||
*/
|
||||
export const scheduleServices = async (
|
||||
services: IUnleashServices,
|
||||
): Promise<void> => {
|
||||
const {
|
||||
accountService,
|
||||
schedulerService,
|
||||
apiTokenService,
|
||||
instanceStatsService,
|
||||
clientInstanceService,
|
||||
projectService,
|
||||
projectHealthService,
|
||||
configurationRevisionService,
|
||||
eventAnnouncerService,
|
||||
featureToggleService,
|
||||
versionService,
|
||||
lastSeenService,
|
||||
proxyService,
|
||||
clientMetricsServiceV2,
|
||||
} = services;
|
||||
|
||||
schedulerService.schedule(
|
||||
lastSeenService.cleanLastSeen.bind(lastSeenService),
|
||||
hoursToMilliseconds(1),
|
||||
'cleanLastSeen',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
lastSeenService.store.bind(lastSeenService),
|
||||
secondsToMilliseconds(30),
|
||||
'storeLastSeen',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
apiTokenService.fetchActiveTokens.bind(apiTokenService),
|
||||
minutesToMilliseconds(1),
|
||||
'fetchActiveTokens',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
apiTokenService.updateLastSeen.bind(apiTokenService),
|
||||
minutesToMilliseconds(3),
|
||||
'updateLastSeen',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
instanceStatsService.refreshStatsSnapshot.bind(instanceStatsService),
|
||||
minutesToMilliseconds(5),
|
||||
'refreshStatsSnapshot',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
clientInstanceService.removeInstancesOlderThanTwoDays.bind(
|
||||
clientInstanceService,
|
||||
),
|
||||
hoursToMilliseconds(24),
|
||||
'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),
|
||||
'statusJob',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
projectHealthService.setHealthRating.bind(projectHealthService),
|
||||
hoursToMilliseconds(1),
|
||||
'setHealthRating',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
configurationRevisionService.updateMaxRevisionId.bind(
|
||||
configurationRevisionService,
|
||||
),
|
||||
secondsToMilliseconds(1),
|
||||
'updateMaxRevisionId',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
eventAnnouncerService.publishUnannouncedEvents.bind(
|
||||
eventAnnouncerService,
|
||||
),
|
||||
secondsToMilliseconds(1),
|
||||
'publishUnannouncedEvents',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
featureToggleService.updatePotentiallyStaleFeatures.bind(
|
||||
featureToggleService,
|
||||
),
|
||||
minutesToMilliseconds(1),
|
||||
'updatePotentiallyStaleFeatures',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
versionService.checkLatestVersion.bind(versionService),
|
||||
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',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
accountService.updateLastSeen.bind(accountService),
|
||||
minutesToMilliseconds(3),
|
||||
'updateAccountLastSeen',
|
||||
);
|
||||
};
|
239
src/lib/features/scheduler/scheduler-service.test.ts
Normal file
239
src/lib/features/scheduler/scheduler-service.test.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { SchedulerService } from './scheduler-service';
|
||||
import { LogProvider } from '../../logger';
|
||||
import MaintenanceService from '../../services/maintenance-service';
|
||||
import { createTestConfig } from '../../../test/config/test-config';
|
||||
import SettingService from '../../services/setting-service';
|
||||
import FakeSettingStore from '../../../test/fixtures/fake-setting-store';
|
||||
import EventService from '../../services/event-service';
|
||||
|
||||
function ms(timeMs) {
|
||||
return new Promise((resolve) => setTimeout(resolve, timeMs));
|
||||
}
|
||||
|
||||
const getLogger = () => {
|
||||
const records: any[] = [];
|
||||
const logger: LogProvider = () => ({
|
||||
error(...args: any[]) {
|
||||
records.push(args);
|
||||
},
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
fatal() {},
|
||||
});
|
||||
const getRecords = () => records;
|
||||
|
||||
return { logger, getRecords };
|
||||
};
|
||||
|
||||
const toggleMaintenanceMode = async (
|
||||
maintenanceService: MaintenanceService,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
await maintenanceService.toggleMaintenanceMode(
|
||||
{ enabled },
|
||||
'irrelevant user',
|
||||
);
|
||||
};
|
||||
|
||||
test('Schedules job immediately', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
|
||||
await schedulerService.schedule(job, 10, 'test-id');
|
||||
|
||||
expect(job).toBeCalledTimes(1);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Does not schedule job immediately when paused', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
|
||||
await toggleMaintenanceMode(maintenanceService, true);
|
||||
await schedulerService.schedule(job, 10, 'test-id-2');
|
||||
|
||||
expect(job).toBeCalledTimes(0);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can schedule a single regular job', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
|
||||
await schedulerService.schedule(job, 50, 'test-id-3');
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(2);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Scheduled job ignored in a paused mode', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
|
||||
await toggleMaintenanceMode(maintenanceService, true);
|
||||
await schedulerService.schedule(job, 50, 'test-id-4');
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(0);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can resume paused job', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
|
||||
await toggleMaintenanceMode(maintenanceService, true);
|
||||
await schedulerService.schedule(job, 50, 'test-id-5');
|
||||
await toggleMaintenanceMode(maintenanceService, false);
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(1);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can schedule multiple jobs at the same interval', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
const anotherJob = jest.fn();
|
||||
|
||||
await schedulerService.schedule(job, 50, 'test-id-6');
|
||||
await schedulerService.schedule(anotherJob, 50, 'test-id-7');
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(2);
|
||||
expect(anotherJob).toBeCalledTimes(2);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can schedule multiple jobs at the different intervals', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
const job = jest.fn();
|
||||
const anotherJob = jest.fn();
|
||||
|
||||
await schedulerService.schedule(job, 100, 'test-id-8');
|
||||
await schedulerService.schedule(anotherJob, 200, 'test-id-9');
|
||||
await ms(250);
|
||||
|
||||
expect(job).toBeCalledTimes(3);
|
||||
expect(anotherJob).toBeCalledTimes(2);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can handle crash of a async job', async () => {
|
||||
const { logger, getRecords } = getLogger();
|
||||
const config = { ...createTestConfig(), logger };
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(logger, maintenanceService);
|
||||
|
||||
const job = async () => {
|
||||
await Promise.reject('async reason');
|
||||
};
|
||||
|
||||
await schedulerService.schedule(job, 50, 'test-id-10');
|
||||
await ms(75);
|
||||
|
||||
schedulerService.stop();
|
||||
expect(getRecords()).toEqual([
|
||||
['scheduled job failed | id: test-id-10 | async reason'],
|
||||
['scheduled job failed | id: test-id-10 | async reason'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Can handle crash of a sync job', async () => {
|
||||
const { logger, getRecords } = getLogger();
|
||||
const config = { ...createTestConfig(), logger };
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(logger, maintenanceService);
|
||||
|
||||
const job = () => {
|
||||
throw new Error('sync reason');
|
||||
};
|
||||
|
||||
await schedulerService.schedule(job, 50, 'test-id-11');
|
||||
await ms(75);
|
||||
|
||||
schedulerService.stop();
|
||||
expect(getRecords()).toEqual([
|
||||
['scheduled job failed | id: test-id-11 | Error: sync reason'],
|
||||
['scheduled job failed | id: test-id-11 | Error: sync reason'],
|
||||
]);
|
||||
});
|
@ -1,17 +1,19 @@
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
|
||||
export type SchedulerMode = 'active' | 'paused';
|
||||
import { Logger, LogProvider } from '../../logger';
|
||||
import MaintenanceService from '../../services/maintenance-service';
|
||||
|
||||
export class SchedulerService {
|
||||
private intervalIds: NodeJS.Timer[] = [];
|
||||
|
||||
private mode: SchedulerMode;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(getLogger: LogProvider) {
|
||||
private maintenanceService: MaintenanceService;
|
||||
|
||||
constructor(
|
||||
getLogger: LogProvider,
|
||||
maintenanceService: MaintenanceService,
|
||||
) {
|
||||
this.logger = getLogger('/services/scheduler-service.ts');
|
||||
this.mode = 'active';
|
||||
this.maintenanceService = maintenanceService;
|
||||
}
|
||||
|
||||
async schedule(
|
||||
@ -22,7 +24,9 @@ export class SchedulerService {
|
||||
this.intervalIds.push(
|
||||
setInterval(async () => {
|
||||
try {
|
||||
if (this.mode === 'active') {
|
||||
const maintenanceMode =
|
||||
await this.maintenanceService.isMaintenanceMode();
|
||||
if (!maintenanceMode) {
|
||||
await scheduledFunction();
|
||||
}
|
||||
} catch (e) {
|
||||
@ -33,7 +37,9 @@ export class SchedulerService {
|
||||
}, timeMs).unref(),
|
||||
);
|
||||
try {
|
||||
if (this.mode === 'active') {
|
||||
const maintenanceMode =
|
||||
await this.maintenanceService.isMaintenanceMode();
|
||||
if (!maintenanceMode) {
|
||||
await scheduledFunction();
|
||||
}
|
||||
} catch (e) {
|
||||
@ -44,16 +50,4 @@ export class SchedulerService {
|
||||
stop(): void {
|
||||
this.intervalIds.forEach(clearInterval);
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.mode = 'paused';
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.mode = 'active';
|
||||
}
|
||||
|
||||
getMode(): SchedulerMode {
|
||||
return this.mode;
|
||||
}
|
||||
}
|
@ -4,15 +4,9 @@ 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,
|
||||
SchedulerService,
|
||||
SettingService,
|
||||
} from '../../lib/services';
|
||||
import { EventService, ProxyService, SettingService } from '../../lib/services';
|
||||
import { ISettingStore } from '../../lib/types';
|
||||
import { frontendSettingsKey } from '../../lib/types/settings/frontend-settings';
|
||||
import { minutesToMilliseconds } from 'date-fns';
|
||||
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
|
||||
|
||||
const createSettingService = (
|
||||
|
@ -173,21 +173,15 @@ export default class ProjectApi extends Controller {
|
||||
req: IAuthRequest,
|
||||
res: Response<ProjectDoraMetricsSchema>,
|
||||
): Promise<void> {
|
||||
if (this.config.flagResolver.isEnabled('doraMetrics')) {
|
||||
const { projectId } = req.params;
|
||||
const { projectId } = req.params;
|
||||
|
||||
const dora = await this.projectService.getDoraMetrics(projectId);
|
||||
const dora = await this.projectService.getDoraMetrics(projectId);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
projectDoraMetricsSchema.$id,
|
||||
dora,
|
||||
);
|
||||
} else {
|
||||
throw new InvalidOperationError(
|
||||
'Feature dora metrics is not enabled',
|
||||
);
|
||||
}
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
projectDoraMetricsSchema.$id,
|
||||
dora,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { migrateDb } from '../migrator';
|
||||
import getApp from './app';
|
||||
import { createMetricsMonitor } from './metrics';
|
||||
import { createStores } from './db';
|
||||
import { createServices, scheduleServices } from './services';
|
||||
import { createServices } from './services';
|
||||
import { createConfig } from './create-config';
|
||||
import registerGracefulShutdown from './util/graceful-shutdown';
|
||||
import { createDb } from './db/db-pool';
|
||||
@ -33,6 +33,7 @@ import * as permissions from './types/permissions';
|
||||
import * as eventType from './types/events';
|
||||
import { Db } from './db/db';
|
||||
import { defaultLockKey, defaultTimeout, withDbLock } from './util/db-lock';
|
||||
import { scheduleServices } from './features/scheduler/schedule-services';
|
||||
|
||||
async function createApp(
|
||||
config: IUnleashConfig,
|
||||
@ -45,7 +46,7 @@ async function createApp(
|
||||
const stores = createStores(config, db);
|
||||
const services = createServices(stores, config, db);
|
||||
if (!config.disableScheduler) {
|
||||
await scheduleServices(services, config.flagResolver);
|
||||
await scheduleServices(services);
|
||||
}
|
||||
|
||||
const metricsMonitor = createMetricsMonitor();
|
||||
|
@ -18,8 +18,6 @@ export class AccountService {
|
||||
|
||||
private accessService: AccessService;
|
||||
|
||||
private seenTimer: NodeJS.Timeout;
|
||||
|
||||
private lastSeenSecrets: Set<string> = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@ -32,7 +30,6 @@ export class AccountService {
|
||||
this.logger = getLogger('service/account-service.ts');
|
||||
this.store = stores.accountStore;
|
||||
this.accessService = services.accessService;
|
||||
this.updateLastSeen();
|
||||
}
|
||||
|
||||
async getAll(): Promise<IUserWithRole[]> {
|
||||
@ -63,19 +60,9 @@ export class AccountService {
|
||||
this.lastSeenSecrets = new Set<string>();
|
||||
await this.store.markSeenAt(toStore);
|
||||
}
|
||||
|
||||
this.seenTimer = setTimeout(
|
||||
async () => this.updateLastSeen(),
|
||||
minutesToMilliseconds(3),
|
||||
).unref();
|
||||
}
|
||||
|
||||
addPATSeen(secret: string): void {
|
||||
this.lastSeenSecrets.add(secret);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearTimeout(this.seenTimer);
|
||||
this.seenTimer = null;
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { secondsToMilliseconds } from 'date-fns';
|
||||
import { Logger } from '../../../logger';
|
||||
import { IUnleashConfig } from '../../../server-impl';
|
||||
import { IClientMetricsEnv } from '../../../types/stores/client-metrics-store-v2';
|
||||
import { ILastSeenStore } from './types/last-seen-store-type';
|
||||
import { IFeatureToggleStore, IUnleashStores } from '../../../../lib/types';
|
||||
import {
|
||||
IFeatureToggleStore,
|
||||
IFlagResolver,
|
||||
IUnleashStores,
|
||||
} from '../../../../lib/types';
|
||||
|
||||
export type LastSeenInput = {
|
||||
featureName: string;
|
||||
@ -21,6 +24,8 @@ export class LastSeenService {
|
||||
|
||||
private config: IUnleashConfig;
|
||||
|
||||
private flagResolver: IFlagResolver;
|
||||
|
||||
constructor(
|
||||
{
|
||||
featureToggleStore,
|
||||
@ -33,6 +38,7 @@ export class LastSeenService {
|
||||
this.logger = config.getLogger(
|
||||
'/services/client-metrics/last-seen-service.ts',
|
||||
);
|
||||
this.flagResolver = config.flagResolver;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@ -75,6 +81,8 @@ export class LastSeenService {
|
||||
}
|
||||
|
||||
async cleanLastSeen() {
|
||||
await this.lastSeenStore.cleanLastSeen();
|
||||
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||
await this.lastSeenStore.cleanLastSeen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,4 @@
|
||||
import {
|
||||
IUnleashConfig,
|
||||
IUnleashStores,
|
||||
IUnleashServices,
|
||||
IFlagResolver,
|
||||
} from '../types';
|
||||
import { IUnleashConfig, IUnleashStores, IUnleashServices } from '../types';
|
||||
import FeatureTypeService from './feature-type-service';
|
||||
import EventService from './event-service';
|
||||
import HealthService from './health-service';
|
||||
@ -44,13 +39,8 @@ import { LastSeenService } from './client-metrics/last-seen/last-seen-service';
|
||||
import { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
||||
import { FavoritesService } from './favorites-service';
|
||||
import MaintenanceService from './maintenance-service';
|
||||
import {
|
||||
hoursToMilliseconds,
|
||||
minutesToMilliseconds,
|
||||
secondsToMilliseconds,
|
||||
} from 'date-fns';
|
||||
import { AccountService } from './account-service';
|
||||
import { SchedulerService } from './scheduler-service';
|
||||
import { SchedulerService } from '../features/scheduler/scheduler-service';
|
||||
import { Knex } from 'knex';
|
||||
import {
|
||||
createExportImportTogglesService,
|
||||
@ -109,149 +99,6 @@ import {
|
||||
} from '../features/feature-search/createFeatureSearchService';
|
||||
import { FeatureSearchService } from '../features/feature-search/feature-search-service';
|
||||
|
||||
// TODO: will be moved to scheduler feature directory
|
||||
export const scheduleServices = async (
|
||||
services: IUnleashServices,
|
||||
flagResolver: IFlagResolver,
|
||||
): Promise<void> => {
|
||||
const {
|
||||
schedulerService,
|
||||
apiTokenService,
|
||||
instanceStatsService,
|
||||
clientInstanceService,
|
||||
projectService,
|
||||
projectHealthService,
|
||||
configurationRevisionService,
|
||||
maintenanceService,
|
||||
eventAnnouncerService,
|
||||
featureToggleService,
|
||||
versionService,
|
||||
lastSeenService,
|
||||
proxyService,
|
||||
clientMetricsServiceV2,
|
||||
} = services;
|
||||
|
||||
if (await maintenanceService.isMaintenanceMode()) {
|
||||
schedulerService.pause();
|
||||
}
|
||||
|
||||
if (flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||
schedulerService.schedule(
|
||||
lastSeenService.cleanLastSeen.bind(lastSeenService),
|
||||
hoursToMilliseconds(1),
|
||||
'cleanLastSeen',
|
||||
);
|
||||
}
|
||||
|
||||
schedulerService.schedule(
|
||||
lastSeenService.store.bind(lastSeenService),
|
||||
secondsToMilliseconds(30),
|
||||
'storeLastSeen',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
apiTokenService.fetchActiveTokens.bind(apiTokenService),
|
||||
minutesToMilliseconds(1),
|
||||
'fetchActiveTokens',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
apiTokenService.updateLastSeen.bind(apiTokenService),
|
||||
minutesToMilliseconds(3),
|
||||
'updateLastSeen',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
instanceStatsService.refreshStatsSnapshot.bind(instanceStatsService),
|
||||
minutesToMilliseconds(5),
|
||||
'refreshStatsSnapshot',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
clientInstanceService.removeInstancesOlderThanTwoDays.bind(
|
||||
clientInstanceService,
|
||||
),
|
||||
hoursToMilliseconds(24),
|
||||
'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),
|
||||
'statusJob',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
projectHealthService.setHealthRating.bind(projectHealthService),
|
||||
hoursToMilliseconds(1),
|
||||
'setHealthRating',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
configurationRevisionService.updateMaxRevisionId.bind(
|
||||
configurationRevisionService,
|
||||
),
|
||||
secondsToMilliseconds(1),
|
||||
'updateMaxRevisionId',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
eventAnnouncerService.publishUnannouncedEvents.bind(
|
||||
eventAnnouncerService,
|
||||
),
|
||||
secondsToMilliseconds(1),
|
||||
'publishUnannouncedEvents',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
featureToggleService.updatePotentiallyStaleFeatures.bind(
|
||||
featureToggleService,
|
||||
),
|
||||
minutesToMilliseconds(1),
|
||||
'updatePotentiallyStaleFeatures',
|
||||
);
|
||||
|
||||
schedulerService.schedule(
|
||||
versionService.checkLatestVersion.bind(versionService),
|
||||
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 = (
|
||||
stores: IUnleashStores,
|
||||
config: IUnleashConfig,
|
||||
@ -438,13 +285,11 @@ export const createServices = (
|
||||
db ? createGetProductionChanges(db) : createFakeGetProductionChanges(),
|
||||
);
|
||||
|
||||
const schedulerService = new SchedulerService(config.getLogger);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
|
||||
const maintenanceService = new MaintenanceService(
|
||||
stores,
|
||||
config,
|
||||
settingService,
|
||||
schedulerService,
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const eventAnnouncerService = new EventAnnouncerService(stores, config);
|
||||
|
@ -1,17 +1,40 @@
|
||||
import { SchedulerService } from './scheduler-service';
|
||||
import { SchedulerService } from '../features/scheduler/scheduler-service';
|
||||
import MaintenanceService from './maintenance-service';
|
||||
import { IUnleashStores } from '../types';
|
||||
import SettingService from './setting-service';
|
||||
import { createTestConfig } from '../../test/config/test-config';
|
||||
import FakeSettingStore from '../../test/fixtures/fake-setting-store';
|
||||
import EventService from './event-service';
|
||||
|
||||
test('Maintenance on should pause scheduler', async () => {
|
||||
test('Scheduler should run scheduled functions if maintenance mode is off', async () => {
|
||||
const config = createTestConfig();
|
||||
const schedulerService = new SchedulerService(config.getLogger);
|
||||
const maintenanceService = new MaintenanceService(
|
||||
{} as IUnleashStores,
|
||||
config,
|
||||
{ insert() {} } as unknown as SettingService,
|
||||
schedulerService,
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
const job = jest.fn();
|
||||
|
||||
await schedulerService.schedule(job, 10, 'test-id');
|
||||
|
||||
expect(job).toBeCalledTimes(1);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Scheduler should not run scheduled functions if maintenance mode is on', async () => {
|
||||
const config = createTestConfig();
|
||||
const settingStore = new FakeSettingStore();
|
||||
const settingService = new SettingService({ settingStore }, config, {
|
||||
storeEvent() {},
|
||||
} as unknown as EventService);
|
||||
const maintenanceService = new MaintenanceService(config, settingService);
|
||||
const schedulerService = new SchedulerService(
|
||||
config.getLogger,
|
||||
maintenanceService,
|
||||
);
|
||||
|
||||
await maintenanceService.toggleMaintenanceMode(
|
||||
@ -19,26 +42,10 @@ test('Maintenance on should pause scheduler', async () => {
|
||||
'irrelevant user',
|
||||
);
|
||||
|
||||
expect(schedulerService.getMode()).toBe('paused');
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Maintenance off should resume scheduler', async () => {
|
||||
const config = createTestConfig({ disableScheduler: false });
|
||||
const schedulerService = new SchedulerService(config.getLogger);
|
||||
schedulerService.pause();
|
||||
const maintenanceService = new MaintenanceService(
|
||||
{} as IUnleashStores,
|
||||
config,
|
||||
{ insert() {} } as unknown as SettingService,
|
||||
schedulerService,
|
||||
);
|
||||
|
||||
await maintenanceService.toggleMaintenanceMode(
|
||||
{ enabled: false },
|
||||
'irrelevant user',
|
||||
);
|
||||
|
||||
expect(schedulerService.getMode()).toBe('active');
|
||||
const job = jest.fn();
|
||||
|
||||
await schedulerService.schedule(job, 10, 'test-id');
|
||||
|
||||
expect(job).toBeCalledTimes(0);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
@ -1,40 +1,20 @@
|
||||
import { IUnleashConfig, IUnleashStores } from '../types';
|
||||
import { IUnleashConfig } from '../types';
|
||||
import { Logger } from '../logger';
|
||||
import { IPatStore } from '../types/stores/pat-store';
|
||||
import { IEventStore } from '../types/stores/event-store';
|
||||
import SettingService from './setting-service';
|
||||
import { maintenanceSettingsKey } from '../types/settings/maintenance-settings';
|
||||
import { MaintenanceSchema } from '../openapi/spec/maintenance-schema';
|
||||
import { SchedulerService } from './scheduler-service';
|
||||
|
||||
export default class MaintenanceService {
|
||||
private config: IUnleashConfig;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private patStore: IPatStore;
|
||||
|
||||
private eventStore: IEventStore;
|
||||
|
||||
private settingService: SettingService;
|
||||
|
||||
private schedulerService: SchedulerService;
|
||||
|
||||
constructor(
|
||||
{
|
||||
patStore,
|
||||
eventStore,
|
||||
}: Pick<IUnleashStores, 'patStore' | 'eventStore'>,
|
||||
config: IUnleashConfig,
|
||||
settingService: SettingService,
|
||||
schedulerService: SchedulerService,
|
||||
) {
|
||||
constructor(config: IUnleashConfig, settingService: SettingService) {
|
||||
this.config = config;
|
||||
this.logger = config.getLogger('services/pat-service.ts');
|
||||
this.patStore = patStore;
|
||||
this.eventStore = eventStore;
|
||||
this.settingService = settingService;
|
||||
this.schedulerService = schedulerService;
|
||||
}
|
||||
|
||||
async isMaintenanceMode(): Promise<boolean> {
|
||||
@ -56,11 +36,6 @@ export default class MaintenanceService {
|
||||
setting: MaintenanceSchema,
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
if (setting.enabled) {
|
||||
this.schedulerService.pause();
|
||||
} else if (!this.config.disableScheduler) {
|
||||
this.schedulerService.resume();
|
||||
}
|
||||
return this.settingService.insert(
|
||||
maintenanceSettingsKey,
|
||||
setting,
|
||||
|
@ -1,148 +0,0 @@
|
||||
import { SchedulerService } from './scheduler-service';
|
||||
import { LogProvider } from '../logger';
|
||||
|
||||
function ms(timeMs) {
|
||||
return new Promise((resolve) => setTimeout(resolve, timeMs));
|
||||
}
|
||||
|
||||
const getLogger = () => {
|
||||
const records: any[] = [];
|
||||
const logger: LogProvider = () => ({
|
||||
error(...args: any[]) {
|
||||
records.push(args);
|
||||
},
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
fatal() {},
|
||||
});
|
||||
const getRecords = () => records;
|
||||
|
||||
return { logger, getRecords };
|
||||
};
|
||||
|
||||
test('Schedules job immediately', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
|
||||
schedulerService.schedule(job, 10, 'test-id');
|
||||
|
||||
expect(job).toBeCalledTimes(1);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Does not schedule job immediately when paused', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
|
||||
schedulerService.pause();
|
||||
schedulerService.schedule(job, 10, 'test-id-2');
|
||||
|
||||
expect(job).toBeCalledTimes(0);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can schedule a single regular job', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
|
||||
schedulerService.schedule(job, 50, 'test-id-3');
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(2);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Scheduled job ignored in a paused mode', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
|
||||
schedulerService.pause();
|
||||
schedulerService.schedule(job, 50, 'test-id-4');
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(0);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can resume paused job', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
|
||||
schedulerService.pause();
|
||||
schedulerService.schedule(job, 50, 'test-id-5');
|
||||
schedulerService.resume();
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(1);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can schedule multiple jobs at the same interval', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
const anotherJob = jest.fn();
|
||||
|
||||
schedulerService.schedule(job, 50, 'test-id-6');
|
||||
schedulerService.schedule(anotherJob, 50, 'test-id-7');
|
||||
await ms(75);
|
||||
|
||||
expect(job).toBeCalledTimes(2);
|
||||
expect(anotherJob).toBeCalledTimes(2);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can schedule multiple jobs at the different intervals', async () => {
|
||||
const { logger } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = jest.fn();
|
||||
const anotherJob = jest.fn();
|
||||
|
||||
schedulerService.schedule(job, 100, 'test-id-8');
|
||||
schedulerService.schedule(anotherJob, 200, 'test-id-9');
|
||||
await ms(250);
|
||||
|
||||
expect(job).toBeCalledTimes(3);
|
||||
expect(anotherJob).toBeCalledTimes(2);
|
||||
schedulerService.stop();
|
||||
});
|
||||
|
||||
test('Can handle crash of a async job', async () => {
|
||||
const { logger, getRecords } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = async () => {
|
||||
await Promise.reject('async reason');
|
||||
};
|
||||
|
||||
schedulerService.schedule(job, 50, 'test-id-10');
|
||||
await ms(75);
|
||||
|
||||
schedulerService.stop();
|
||||
expect(getRecords()).toEqual([
|
||||
['scheduled job failed | id: test-id-10 | async reason'],
|
||||
['scheduled job failed | id: test-id-10 | async reason'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Can handle crash of a sync job', async () => {
|
||||
const { logger, getRecords } = getLogger();
|
||||
const schedulerService = new SchedulerService(logger);
|
||||
const job = () => {
|
||||
throw new Error('sync reason');
|
||||
};
|
||||
|
||||
schedulerService.schedule(job, 50, 'test-id-11');
|
||||
await ms(75);
|
||||
|
||||
schedulerService.stop();
|
||||
expect(getRecords()).toEqual([
|
||||
['scheduled job failed | id: test-id-11 | Error: sync reason'],
|
||||
['scheduled job failed | id: test-id-11 | Error: sync reason'],
|
||||
]);
|
||||
});
|
@ -125,7 +125,9 @@ export class SegmentService implements ISegmentService {
|
||||
await this.changeRequestSegmentUsageReadModel.getStrategiesUsedInActiveChangeRequests(
|
||||
id,
|
||||
)
|
||||
).filter((strategy) => !strategyIds.has(strategy.id));
|
||||
).filter(
|
||||
(strategy) => !('id' in strategy && strategyIds.has(strategy.id)),
|
||||
);
|
||||
|
||||
return { strategies, changeRequestStrategies };
|
||||
}
|
||||
|
@ -23,8 +23,6 @@ export type IFlagKey =
|
||||
| 'filterInvalidClientMetrics'
|
||||
| 'lastSeenByEnvironment'
|
||||
| 'customRootRolesKillSwitch'
|
||||
| 'featureNamingPattern'
|
||||
| 'doraMetrics'
|
||||
| 'variantTypeNumber'
|
||||
| 'privateProjects'
|
||||
| 'dependentFeatures'
|
||||
@ -116,14 +114,6 @@ const flags: IFlags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES_KILL_SWITCH,
|
||||
false,
|
||||
),
|
||||
featureNamingPattern: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_FEATURE_NAMING_PATTERN,
|
||||
false,
|
||||
),
|
||||
doraMetrics: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_DORA_METRICS,
|
||||
false,
|
||||
),
|
||||
dependentFeatures: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_DEPENDENT_FEATURES,
|
||||
false,
|
||||
|
@ -37,7 +37,7 @@ import { InstanceStatsService } from '../features/instance-stats/instance-stats-
|
||||
import { FavoritesService } from '../services/favorites-service';
|
||||
import MaintenanceService from '../services/maintenance-service';
|
||||
import { AccountService } from '../services/account-service';
|
||||
import { SchedulerService } from '../services/scheduler-service';
|
||||
import { SchedulerService } from '../features/scheduler/scheduler-service';
|
||||
import { Knex } from 'knex';
|
||||
import {
|
||||
IExportService,
|
||||
|
@ -38,8 +38,6 @@ process.nextTick(async () => {
|
||||
anonymiseEventLog: false,
|
||||
responseTimeWithAppNameKillSwitch: false,
|
||||
lastSeenByEnvironment: true,
|
||||
featureNamingPattern: true,
|
||||
doraMetrics: true,
|
||||
variantTypeNumber: true,
|
||||
privateProjects: true,
|
||||
dependentFeatures: true,
|
||||
|
@ -21,7 +21,6 @@ beforeAll(async () => {
|
||||
experimental: {
|
||||
flags: {
|
||||
strictSchemaValidation: true,
|
||||
featureNamingPattern: true,
|
||||
dependentFeatures: true,
|
||||
},
|
||||
},
|
||||
|
@ -75,6 +75,8 @@ Unleash currently supports these payload types:
|
||||
When you have only one variant in a strategy, stickiness does not matter. If you decide to add multiple variants to the strategy, then variant stickiness is derived from the strategy stickiness.
|
||||
Strategy stickiness is calculated on the received user and context, as described in [the stickiness chapter](./stickiness.md). This ensures that the same user will consistently see the same variant. If no context data is provided, the traffic will be spread randomly for each request.
|
||||
|
||||
If you would like to reassign users to different variants using existing stickiness parameter then you can change the groupId of the strategy. This will provide different input to the stickiness calculation.
|
||||
|
||||
### Strategy variants vs feature toggle variants
|
||||
|
||||
Strategy variants take precedence over the [feature toggle variants](./feature-toggle-variants.md). If your matching activation strategy doesn't have any variants configured you will fall back to the [feature toggle variants](./feature-toggle-variants.md).
|
||||
|
Loading…
Reference in New Issue
Block a user