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 { useState, useEffect } from 'react';
|
||||||
import { Box, Button, Divider, Typography, styled } from '@mui/material';
|
import { Box, Button, Divider, Typography, styled } from '@mui/material';
|
||||||
import { PermMedia, Send } from '@mui/icons-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 { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
@ -61,12 +61,21 @@ const StyledLink = styled('a')(({ theme }) => ({
|
|||||||
textDecoration: 'none',
|
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 { trackEvent } = usePlausibleTracker();
|
||||||
const { value, setValue } = createLocalStorage(
|
const { value, setValue } = createLocalStorage(trackerKey, { sent: false });
|
||||||
`project:metrics:plausible`,
|
|
||||||
{ sent: false },
|
|
||||||
);
|
|
||||||
const [metrics, setMetrics] = useState(value);
|
const [metrics, setMetrics] = useState(value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -75,7 +84,7 @@ export const ProjectDoraFeedback = () => {
|
|||||||
|
|
||||||
const onBtnClick = (type: string) => {
|
const onBtnClick = (type: string) => {
|
||||||
try {
|
try {
|
||||||
trackEvent('project-metrics', {
|
trackEvent(eventKey, {
|
||||||
props: {
|
props: {
|
||||||
eventType: type,
|
eventType: type,
|
||||||
},
|
},
|
||||||
@ -91,7 +100,7 @@ export const ProjectDoraFeedback = () => {
|
|||||||
|
|
||||||
const recipientEmail = 'ux@getunleash.io';
|
const recipientEmail = 'ux@getunleash.io';
|
||||||
const emailSubject = "I'd like to get involved";
|
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(
|
const mailtoURL = `mailto:${recipientEmail}?subject=${encodeURIComponent(
|
||||||
emailSubject,
|
emailSubject,
|
||||||
@ -102,31 +111,10 @@ export const ProjectDoraFeedback = () => {
|
|||||||
<StyledHeader variant='h1'>
|
<StyledHeader variant='h1'>
|
||||||
We are trying something experimental!
|
We are trying something experimental!
|
||||||
</StyledHeader>
|
</StyledHeader>
|
||||||
<Typography>
|
<Typography>{description}</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>
|
|
||||||
|
|
||||||
<br />
|
<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
|
<ConditionallyRender
|
||||||
condition={!metrics.sent}
|
condition={!metrics.sent}
|
||||||
show={
|
show={
|
||||||
@ -170,7 +158,7 @@ export const ProjectDoraFeedback = () => {
|
|||||||
<PermMedia />
|
<PermMedia />
|
||||||
</StyledIconWrapper>
|
</StyledIconWrapper>
|
||||||
<StyledLink
|
<StyledLink
|
||||||
href='https://app.mural.co/t/unleash2757/m/unleash2757/1694006366166/fae4aa4f796de214bdb3ae2d5ce9de934b68fdfb?sender=u777a1f5633477c329eae3448'
|
href={sketchURL}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferer'
|
rel='noopener noreferer'
|
||||||
>
|
>
|
@ -23,7 +23,6 @@ import React from 'react';
|
|||||||
import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
|
import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions';
|
||||||
import { FeatureNamingType } from 'interfaces/project';
|
import { FeatureNamingType } from 'interfaces/project';
|
||||||
import { FeatureNamingPatternInfo } from '../FeatureNamingPatternInfo/FeatureNamingPatternInfo';
|
import { FeatureNamingPatternInfo } from '../FeatureNamingPatternInfo/FeatureNamingPatternInfo';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
|
||||||
|
|
||||||
interface IFeatureToggleForm {
|
interface IFeatureToggleForm {
|
||||||
type: string;
|
type: string;
|
||||||
@ -122,15 +121,12 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { permissions } = useAuthPermissions();
|
const { permissions } = useAuthPermissions();
|
||||||
const editable = mode !== 'Edit';
|
const editable = mode !== 'Edit';
|
||||||
const featureNamingPatternEnabled = useUiFlag('featureNamingPattern');
|
|
||||||
|
|
||||||
const renderToggleDescription = () => {
|
const renderToggleDescription = () => {
|
||||||
return featureTypes.find((toggle) => toggle.id === type)?.description;
|
return featureTypes.find((toggle) => toggle.id === type)?.description;
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayFeatureNamingInfo = Boolean(
|
const displayFeatureNamingInfo = Boolean(featureNaming?.pattern);
|
||||||
featureNamingPatternEnabled && featureNaming?.pattern,
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (featureNaming?.pattern && validateToggleName && name) {
|
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 { ProjectDoraMetrics } from './ProjectDoraMetrics/ProjectDoraMetrics';
|
||||||
import { UiFlags } from 'interfaces/uiConfig';
|
import { UiFlags } from 'interfaces/uiConfig';
|
||||||
import { ExperimentalProjectFeatures } from './ExperimentalProjectFeatures/ExperimentalProjectFeatures';
|
import { ExperimentalProjectFeatures } from './ExperimentalProjectFeatures/ExperimentalProjectFeatures';
|
||||||
|
import { HiddenProjectIconWithTooltip } from './HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip';
|
||||||
|
|
||||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -102,8 +103,7 @@ export const Project = () => {
|
|||||||
title: 'Metrics',
|
title: 'Metrics',
|
||||||
path: `${basePath}/metrics`,
|
path: `${basePath}/metrics`,
|
||||||
name: 'dora',
|
name: 'dora',
|
||||||
flag: 'doraMetrics',
|
isEnterprise: true,
|
||||||
new: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Event log',
|
title: 'Event log',
|
||||||
@ -190,6 +190,10 @@ export const Project = () => {
|
|||||||
isFavorite={project?.favorite}
|
isFavorite={project?.favorite}
|
||||||
/>
|
/>
|
||||||
<StyledProjectTitle>
|
<StyledProjectTitle>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={project?.mode === 'private'}
|
||||||
|
show={<HiddenProjectIconWithTooltip />}
|
||||||
|
/>
|
||||||
<StyledName data-loading>
|
<StyledName data-loading>
|
||||||
{projectName}
|
{projectName}
|
||||||
</StyledName>
|
</StyledName>
|
||||||
|
@ -15,7 +15,6 @@ import { PageContent } from 'component/common/PageContent/PageContent';
|
|||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
import { Badge } from 'component/common/Badge/Badge';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
import { ProjectDoraFeedback } from './ProjectDoraFeedback/ProjectDoraFeedback';
|
|
||||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||||
import theme from 'themes/theme';
|
import theme from 'themes/theme';
|
||||||
|
|
||||||
@ -194,7 +193,6 @@ export const ProjectDoraMetrics = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProjectDoraFeedback />
|
|
||||||
<PageContent
|
<PageContent
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
header={
|
header={
|
||||||
|
@ -23,13 +23,13 @@ export const CollaborationModeTooltip: FC = () => {
|
|||||||
<Box>
|
<Box>
|
||||||
<StyledTitle>open: </StyledTitle>
|
<StyledTitle>open: </StyledTitle>
|
||||||
<StyledDescription>
|
<StyledDescription>
|
||||||
everyone can submit change requests
|
Everyone can submit change requests
|
||||||
</StyledDescription>
|
</StyledDescription>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<StyledTitle>protected: </StyledTitle>
|
<StyledTitle>protected: </StyledTitle>
|
||||||
<StyledDescription>
|
<StyledDescription>
|
||||||
only admins and project members can submit change
|
Only admins and project members can submit change
|
||||||
requests
|
requests
|
||||||
</StyledDescription>
|
</StyledDescription>
|
||||||
</Box>
|
</Box>
|
||||||
@ -39,8 +39,9 @@ export const CollaborationModeTooltip: FC = () => {
|
|||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<StyledTitle>private: </StyledTitle>
|
<StyledTitle>private: </StyledTitle>
|
||||||
<StyledDescription>
|
<StyledDescription>
|
||||||
only projects members can and access see the
|
Only admins, editors and project members can
|
||||||
project
|
see and access the project and associated
|
||||||
|
feature toggles
|
||||||
</StyledDescription>
|
</StyledDescription>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
|
@ -136,7 +136,6 @@ const ProjectEnterpriseSettingsForm: React.FC<IProjectEnterpriseSettingsForm> =
|
|||||||
clearErrors,
|
clearErrors,
|
||||||
}) => {
|
}) => {
|
||||||
const privateProjects = useUiFlag('privateProjects');
|
const privateProjects = useUiFlag('privateProjects');
|
||||||
const shouldShowFlagNaming = useUiFlag('featureNamingPattern');
|
|
||||||
|
|
||||||
const { setPreviousPattern, trackPattern } =
|
const { setPreviousPattern, trackPattern } =
|
||||||
useFeatureNamePatternTracking();
|
useFeatureNamePatternTracking();
|
||||||
@ -253,115 +252,104 @@ const ProjectEnterpriseSettingsForm: React.FC<IProjectEnterpriseSettingsForm> =
|
|||||||
options={projectModeOptions}
|
options={projectModeOptions}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
<ConditionallyRender
|
<StyledFieldset>
|
||||||
condition={Boolean(shouldShowFlagNaming)}
|
<Box
|
||||||
show={
|
sx={{
|
||||||
<StyledFieldset>
|
display: 'flex',
|
||||||
<Box
|
alignItems: 'center',
|
||||||
sx={{
|
marginBottom: 1,
|
||||||
display: 'flex',
|
gap: 1,
|
||||||
alignItems: 'center',
|
}}
|
||||||
marginBottom: 1,
|
>
|
||||||
gap: 1,
|
<legend>Feature flag naming pattern?</legend>
|
||||||
}}
|
<FeatureFlagNamingTooltip />
|
||||||
>
|
</Box>
|
||||||
<legend>Feature flag naming pattern?</legend>
|
<StyledSubtitle>
|
||||||
<FeatureFlagNamingTooltip />
|
<StyledPatternNamingExplanation id='pattern-naming-description'>
|
||||||
</Box>
|
<p>
|
||||||
<StyledSubtitle>
|
Define a{' '}
|
||||||
<StyledPatternNamingExplanation id='pattern-naming-description'>
|
<a
|
||||||
<p>
|
href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`}
|
||||||
Define a{' '}
|
target='_blank'
|
||||||
<a
|
rel='noreferrer'
|
||||||
href={`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Cheatsheet`}
|
>
|
||||||
target='_blank'
|
JavaScript RegEx
|
||||||
rel='noreferrer'
|
</a>{' '}
|
||||||
>
|
used to enforce feature flag names within this
|
||||||
JavaScript RegEx
|
project. The regex will be surrounded by a
|
||||||
</a>{' '}
|
leading <code>^</code> and a trailing{' '}
|
||||||
used to enforce feature flag names
|
<code>$</code>.
|
||||||
within this project. The regex will be
|
</p>
|
||||||
surrounded by a leading <code>^</code>{' '}
|
<p>
|
||||||
and a trailing <code>$</code>.
|
Leave it empty if you don’t want to add a naming
|
||||||
</p>
|
pattern.
|
||||||
<p>
|
</p>
|
||||||
Leave it empty if you don’t want to add
|
</StyledPatternNamingExplanation>
|
||||||
a naming pattern.
|
</StyledSubtitle>
|
||||||
</p>
|
<StyledFlagNamingContainer>
|
||||||
</StyledPatternNamingExplanation>
|
<StyledInput
|
||||||
</StyledSubtitle>
|
label={'Naming Pattern'}
|
||||||
<StyledFlagNamingContainer>
|
name='feature flag naming pattern'
|
||||||
<StyledInput
|
aria-describedby='pattern-naming-description'
|
||||||
label={'Naming Pattern'}
|
placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+'
|
||||||
name='feature flag naming pattern'
|
InputProps={{
|
||||||
aria-describedby='pattern-naming-description'
|
startAdornment: (
|
||||||
placeholder='[A-Za-z]+.[A-Za-z]+.[A-Za-z0-9-]+'
|
<InputAdornment position='start'>
|
||||||
InputProps={{
|
^
|
||||||
startAdornment: (
|
</InputAdornment>
|
||||||
<InputAdornment position='start'>
|
),
|
||||||
^
|
endAdornment: (
|
||||||
</InputAdornment>
|
<InputAdornment position='end'>
|
||||||
),
|
$
|
||||||
endAdornment: (
|
</InputAdornment>
|
||||||
<InputAdornment position='end'>
|
),
|
||||||
$
|
}}
|
||||||
</InputAdornment>
|
type={'text'}
|
||||||
),
|
value={featureNamingPattern || ''}
|
||||||
}}
|
error={Boolean(errors.featureNamingPattern)}
|
||||||
type={'text'}
|
errorText={errors.featureNamingPattern}
|
||||||
value={featureNamingPattern || ''}
|
onChange={(e) =>
|
||||||
error={Boolean(errors.featureNamingPattern)}
|
onSetFeatureNamingPattern(e.target.value)
|
||||||
errorText={errors.featureNamingPattern}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
onSetFeatureNamingPattern(
|
<StyledSubtitle>
|
||||||
e.target.value,
|
<p id='pattern-additional-description'>
|
||||||
)
|
The example and description will be shown to
|
||||||
}
|
users when they create a new feature flag in
|
||||||
/>
|
this project.
|
||||||
<StyledSubtitle>
|
</p>
|
||||||
<p id='pattern-additional-description'>
|
</StyledSubtitle>
|
||||||
The example and description will be
|
|
||||||
shown to users when they create a new
|
|
||||||
feature flag in this project.
|
|
||||||
</p>
|
|
||||||
</StyledSubtitle>
|
|
||||||
|
|
||||||
<StyledInput
|
<StyledInput
|
||||||
label={'Naming Example'}
|
label={'Naming Example'}
|
||||||
name='feature flag naming example'
|
name='feature flag naming example'
|
||||||
type={'text'}
|
type={'text'}
|
||||||
aria-describedby='pattern-additional-description'
|
aria-describedby='pattern-additional-description'
|
||||||
value={featureNamingExample || ''}
|
value={featureNamingExample || ''}
|
||||||
placeholder='dx.feature1.1-135'
|
placeholder='dx.feature1.1-135'
|
||||||
error={Boolean(errors.namingExample)}
|
error={Boolean(errors.namingExample)}
|
||||||
errorText={errors.namingExample}
|
errorText={errors.namingExample}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSetFeatureNamingExample(
|
onSetFeatureNamingExample(e.target.value)
|
||||||
e.target.value,
|
}
|
||||||
)
|
/>
|
||||||
}
|
<StyledTextField
|
||||||
/>
|
label={'Naming pattern description'}
|
||||||
<StyledTextField
|
name='feature flag naming description'
|
||||||
label={'Naming pattern description'}
|
type={'text'}
|
||||||
name='feature flag naming description'
|
aria-describedby='pattern-additional-description'
|
||||||
type={'text'}
|
placeholder={`<project>.<featureName>.<ticket>
|
||||||
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.`}
|
The flag name should contain the project name, the feature name, and the ticket number, each separated by a dot.`}
|
||||||
multiline
|
multiline
|
||||||
minRows={5}
|
minRows={5}
|
||||||
value={featureNamingDescription || ''}
|
value={featureNamingDescription || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSetFeatureNamingDescription(
|
onSetFeatureNamingDescription(e.target.value)
|
||||||
e.target.value,
|
}
|
||||||
)
|
/>
|
||||||
}
|
</StyledFlagNamingContainer>
|
||||||
/>
|
</StyledFieldset>
|
||||||
</StyledFlagNamingContainer>
|
|
||||||
</StyledFieldset>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StyledButtonContainer>{children}</StyledButtonContainer>
|
<StyledButtonContainer>{children}</StyledButtonContainer>
|
||||||
</StyledForm>
|
</StyledForm>
|
||||||
);
|
);
|
||||||
|
@ -75,3 +75,8 @@ export const StyledParagraphInfo = styled('p')(({ theme }) => ({
|
|||||||
color: theme.palette.primary.dark,
|
color: theme.palette.primary.dark,
|
||||||
fontWeight: 'bold',
|
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 MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import React, { SyntheticEvent, useContext, useState } from 'react';
|
import React, { SyntheticEvent, useContext, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@ -26,8 +26,10 @@ import {
|
|||||||
StyledDivInfo,
|
StyledDivInfo,
|
||||||
StyledDivInfoContainer,
|
StyledDivInfoContainer,
|
||||||
StyledParagraphInfo,
|
StyledParagraphInfo,
|
||||||
|
StyledIconBox,
|
||||||
} from './ProjectCard.styles';
|
} from './ProjectCard.styles';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
|
import { HiddenProjectIconWithTooltip } from '../Project/HiddenProjectIconWithTooltip/HiddenProjectIconWithTooltip';
|
||||||
|
|
||||||
interface IProjectCardProps {
|
interface IProjectCardProps {
|
||||||
name: string;
|
name: string;
|
||||||
@ -37,6 +39,7 @@ interface IProjectCardProps {
|
|||||||
id: string;
|
id: string;
|
||||||
onHover: () => void;
|
onHover: () => void;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
mode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectCard = ({
|
export const ProjectCard = ({
|
||||||
@ -46,6 +49,7 @@ export const ProjectCard = ({
|
|||||||
memberCount,
|
memberCount,
|
||||||
onHover,
|
onHover,
|
||||||
id,
|
id,
|
||||||
|
mode,
|
||||||
isFavorite = false,
|
isFavorite = false,
|
||||||
}: IProjectCardProps) => {
|
}: IProjectCardProps) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
@ -127,9 +131,13 @@ export const ProjectCard = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</StyledDivHeader>
|
</StyledDivHeader>
|
||||||
<div data-loading>
|
<StyledIconBox data-loading>
|
||||||
<StyledProjectIcon />
|
<ConditionallyRender
|
||||||
</div>
|
condition={mode === 'private'}
|
||||||
|
show={<HiddenProjectIconWithTooltip />}
|
||||||
|
elseShow={<StyledProjectIcon />}
|
||||||
|
/>
|
||||||
|
</StyledIconBox>
|
||||||
<StyledDivInfo>
|
<StyledDivInfo>
|
||||||
<StyledDivInfoContainer>
|
<StyledDivInfoContainer>
|
||||||
<StyledParagraphInfo data-loading>
|
<StyledParagraphInfo data-loading>
|
||||||
|
@ -245,6 +245,7 @@ export const ProjectListNew = () => {
|
|||||||
key={project.id}
|
key={project.id}
|
||||||
name={project.name}
|
name={project.name}
|
||||||
id={project.id}
|
id={project.id}
|
||||||
|
mode={project.mode}
|
||||||
memberCount={2}
|
memberCount={2}
|
||||||
health={95}
|
health={95}
|
||||||
featureCount={4}
|
featureCount={4}
|
||||||
@ -263,6 +264,7 @@ export const ProjectListNew = () => {
|
|||||||
handleHover(project.id)
|
handleHover(project.id)
|
||||||
}
|
}
|
||||||
name={project.name}
|
name={project.name}
|
||||||
|
mode={project.mode}
|
||||||
memberCount={
|
memberCount={
|
||||||
project.memberCount ?? 0
|
project.memberCount ?? 0
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ const loadingData = [
|
|||||||
featureCount: 4,
|
featureCount: 4,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
mode: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loading2',
|
id: 'loading2',
|
||||||
@ -16,6 +17,7 @@ const loadingData = [
|
|||||||
featureCount: 4,
|
featureCount: 4,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
mode: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loading3',
|
id: 'loading3',
|
||||||
@ -25,6 +27,7 @@ const loadingData = [
|
|||||||
featureCount: 4,
|
featureCount: 4,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
mode: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'loading4',
|
id: 'loading4',
|
||||||
@ -34,6 +37,7 @@ const loadingData = [
|
|||||||
featureCount: 4,
|
featureCount: 4,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
description: '',
|
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;
|
health: number;
|
||||||
description: string;
|
description: string;
|
||||||
featureCount: number;
|
featureCount: number;
|
||||||
|
mode: string;
|
||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,6 @@ export type UiFlags = {
|
|||||||
customRootRolesKillSwitch?: boolean;
|
customRootRolesKillSwitch?: boolean;
|
||||||
strategyVariant?: boolean;
|
strategyVariant?: boolean;
|
||||||
lastSeenByEnvironment?: boolean;
|
lastSeenByEnvironment?: boolean;
|
||||||
featureNamingPattern?: boolean;
|
|
||||||
doraMetrics?: boolean;
|
doraMetrics?: boolean;
|
||||||
variantTypeNumber?: boolean;
|
variantTypeNumber?: boolean;
|
||||||
privateProjects?: boolean;
|
privateProjects?: boolean;
|
||||||
|
@ -81,10 +81,8 @@ exports[`should create default config 1`] = `
|
|||||||
"disableEnvsOnRevive": false,
|
"disableEnvsOnRevive": false,
|
||||||
"disableMetrics": false,
|
"disableMetrics": false,
|
||||||
"disableNotifications": false,
|
"disableNotifications": false,
|
||||||
"doraMetrics": false,
|
|
||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
"embedProxyFrontend": true,
|
"embedProxyFrontend": true,
|
||||||
"featureNamingPattern": false,
|
|
||||||
"featureSearchAPI": false,
|
"featureSearchAPI": false,
|
||||||
"featureSearchFrontend": false,
|
"featureSearchFrontend": false,
|
||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
|
@ -159,7 +159,6 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
featuresExportImport: true,
|
featuresExportImport: true,
|
||||||
featureNamingPattern: true,
|
|
||||||
dependentFeatures: true,
|
dependentFeatures: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1168,8 +1168,9 @@ class FeatureToggleService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
featureNames: string[],
|
featureNames: string[],
|
||||||
): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
): Promise<FeatureNameCheckResultWithFeaturePattern> {
|
||||||
if (this.flagResolver.isEnabled('featureNamingPattern')) {
|
try {
|
||||||
const project = await this.projectStore.get(projectId);
|
const project = await this.projectStore.get(projectId);
|
||||||
|
|
||||||
const patternData = project.featureNaming;
|
const patternData = project.featureNaming;
|
||||||
const namingPattern = patternData?.pattern;
|
const namingPattern = patternData?.pattern;
|
||||||
|
|
||||||
@ -1183,7 +1184,17 @@ class FeatureToggleService {
|
|||||||
return { ...result, featureNaming: patternData };
|
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' };
|
return { state: 'valid' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -709,7 +709,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
const [, envName] = sortBy.split(':');
|
const [, envName] = sortBy.split(':');
|
||||||
rankingSql += this.db
|
rankingSql += this.db
|
||||||
.raw(
|
.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],
|
[envName],
|
||||||
)
|
)
|
||||||
.toString();
|
.toString();
|
||||||
@ -718,9 +718,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
.raw(`?? ${validatedSortOrder}`, [
|
.raw(`?? ${validatedSortOrder}`, [
|
||||||
sortByMapping[sortBy],
|
sortByMapping[sortBy],
|
||||||
])
|
])
|
||||||
.toString()}, features.created_at asc`;
|
.toString()}, features.created_at asc, features.name asc`;
|
||||||
} else {
|
} else {
|
||||||
rankingSql += `features.created_at ${validatedSortOrder}`;
|
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
|
||||||
}
|
}
|
||||||
|
|
||||||
query
|
query
|
||||||
|
@ -39,7 +39,7 @@ const irrelevantDate = new Date();
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
experimental: {
|
experimental: {
|
||||||
flags: { featureNamingPattern: true, playgroundImprovements: true },
|
flags: { playgroundImprovements: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
db = await dbInit(
|
db = await dbInit(
|
||||||
@ -641,6 +641,20 @@ describe('flag name validation', () => {
|
|||||||
).resolves.toBeFalsy();
|
).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 () => {
|
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';
|
import { Logger, LogProvider } from '../../logger';
|
||||||
|
import MaintenanceService from '../../services/maintenance-service';
|
||||||
export type SchedulerMode = 'active' | 'paused';
|
|
||||||
|
|
||||||
export class SchedulerService {
|
export class SchedulerService {
|
||||||
private intervalIds: NodeJS.Timer[] = [];
|
private intervalIds: NodeJS.Timer[] = [];
|
||||||
|
|
||||||
private mode: SchedulerMode;
|
|
||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(getLogger: LogProvider) {
|
private maintenanceService: MaintenanceService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
getLogger: LogProvider,
|
||||||
|
maintenanceService: MaintenanceService,
|
||||||
|
) {
|
||||||
this.logger = getLogger('/services/scheduler-service.ts');
|
this.logger = getLogger('/services/scheduler-service.ts');
|
||||||
this.mode = 'active';
|
this.maintenanceService = maintenanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
async schedule(
|
async schedule(
|
||||||
@ -22,7 +24,9 @@ export class SchedulerService {
|
|||||||
this.intervalIds.push(
|
this.intervalIds.push(
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
if (this.mode === 'active') {
|
const maintenanceMode =
|
||||||
|
await this.maintenanceService.isMaintenanceMode();
|
||||||
|
if (!maintenanceMode) {
|
||||||
await scheduledFunction();
|
await scheduledFunction();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -33,7 +37,9 @@ export class SchedulerService {
|
|||||||
}, timeMs).unref(),
|
}, timeMs).unref(),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
if (this.mode === 'active') {
|
const maintenanceMode =
|
||||||
|
await this.maintenanceService.isMaintenanceMode();
|
||||||
|
if (!maintenanceMode) {
|
||||||
await scheduledFunction();
|
await scheduledFunction();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -44,16 +50,4 @@ export class SchedulerService {
|
|||||||
stop(): void {
|
stop(): void {
|
||||||
this.intervalIds.forEach(clearInterval);
|
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 FakeEventStore from '../../test/fixtures/fake-event-store';
|
||||||
import { randomId } from '../util/random-id';
|
import { randomId } from '../util/random-id';
|
||||||
import FakeProjectStore from '../../test/fixtures/fake-project-store';
|
import FakeProjectStore from '../../test/fixtures/fake-project-store';
|
||||||
import {
|
import { EventService, ProxyService, SettingService } from '../../lib/services';
|
||||||
EventService,
|
|
||||||
ProxyService,
|
|
||||||
SchedulerService,
|
|
||||||
SettingService,
|
|
||||||
} from '../../lib/services';
|
|
||||||
import { ISettingStore } from '../../lib/types';
|
import { ISettingStore } from '../../lib/types';
|
||||||
import { frontendSettingsKey } from '../../lib/types/settings/frontend-settings';
|
import { frontendSettingsKey } from '../../lib/types/settings/frontend-settings';
|
||||||
import { minutesToMilliseconds } from 'date-fns';
|
|
||||||
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
|
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
|
||||||
|
|
||||||
const createSettingService = (
|
const createSettingService = (
|
||||||
|
@ -173,21 +173,15 @@ export default class ProjectApi extends Controller {
|
|||||||
req: IAuthRequest,
|
req: IAuthRequest,
|
||||||
res: Response<ProjectDoraMetricsSchema>,
|
res: Response<ProjectDoraMetricsSchema>,
|
||||||
): Promise<void> {
|
): 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(
|
this.openApiService.respondWithValidation(
|
||||||
200,
|
200,
|
||||||
res,
|
res,
|
||||||
projectDoraMetricsSchema.$id,
|
projectDoraMetricsSchema.$id,
|
||||||
dora,
|
dora,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
throw new InvalidOperationError(
|
|
||||||
'Feature dora metrics is not enabled',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import { migrateDb } from '../migrator';
|
|||||||
import getApp from './app';
|
import getApp from './app';
|
||||||
import { createMetricsMonitor } from './metrics';
|
import { createMetricsMonitor } from './metrics';
|
||||||
import { createStores } from './db';
|
import { createStores } from './db';
|
||||||
import { createServices, scheduleServices } from './services';
|
import { createServices } from './services';
|
||||||
import { createConfig } from './create-config';
|
import { createConfig } from './create-config';
|
||||||
import registerGracefulShutdown from './util/graceful-shutdown';
|
import registerGracefulShutdown from './util/graceful-shutdown';
|
||||||
import { createDb } from './db/db-pool';
|
import { createDb } from './db/db-pool';
|
||||||
@ -33,6 +33,7 @@ import * as permissions from './types/permissions';
|
|||||||
import * as eventType from './types/events';
|
import * as eventType from './types/events';
|
||||||
import { Db } from './db/db';
|
import { Db } from './db/db';
|
||||||
import { defaultLockKey, defaultTimeout, withDbLock } from './util/db-lock';
|
import { defaultLockKey, defaultTimeout, withDbLock } from './util/db-lock';
|
||||||
|
import { scheduleServices } from './features/scheduler/schedule-services';
|
||||||
|
|
||||||
async function createApp(
|
async function createApp(
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -45,7 +46,7 @@ async function createApp(
|
|||||||
const stores = createStores(config, db);
|
const stores = createStores(config, db);
|
||||||
const services = createServices(stores, config, db);
|
const services = createServices(stores, config, db);
|
||||||
if (!config.disableScheduler) {
|
if (!config.disableScheduler) {
|
||||||
await scheduleServices(services, config.flagResolver);
|
await scheduleServices(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
const metricsMonitor = createMetricsMonitor();
|
const metricsMonitor = createMetricsMonitor();
|
||||||
|
@ -18,8 +18,6 @@ export class AccountService {
|
|||||||
|
|
||||||
private accessService: AccessService;
|
private accessService: AccessService;
|
||||||
|
|
||||||
private seenTimer: NodeJS.Timeout;
|
|
||||||
|
|
||||||
private lastSeenSecrets: Set<string> = new Set<string>();
|
private lastSeenSecrets: Set<string> = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -32,7 +30,6 @@ export class AccountService {
|
|||||||
this.logger = getLogger('service/account-service.ts');
|
this.logger = getLogger('service/account-service.ts');
|
||||||
this.store = stores.accountStore;
|
this.store = stores.accountStore;
|
||||||
this.accessService = services.accessService;
|
this.accessService = services.accessService;
|
||||||
this.updateLastSeen();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(): Promise<IUserWithRole[]> {
|
async getAll(): Promise<IUserWithRole[]> {
|
||||||
@ -63,19 +60,9 @@ export class AccountService {
|
|||||||
this.lastSeenSecrets = new Set<string>();
|
this.lastSeenSecrets = new Set<string>();
|
||||||
await this.store.markSeenAt(toStore);
|
await this.store.markSeenAt(toStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.seenTimer = setTimeout(
|
|
||||||
async () => this.updateLastSeen(),
|
|
||||||
minutesToMilliseconds(3),
|
|
||||||
).unref();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addPATSeen(secret: string): void {
|
addPATSeen(secret: string): void {
|
||||||
this.lastSeenSecrets.add(secret);
|
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 { Logger } from '../../../logger';
|
||||||
import { IUnleashConfig } from '../../../server-impl';
|
import { IUnleashConfig } from '../../../server-impl';
|
||||||
import { IClientMetricsEnv } from '../../../types/stores/client-metrics-store-v2';
|
import { IClientMetricsEnv } from '../../../types/stores/client-metrics-store-v2';
|
||||||
import { ILastSeenStore } from './types/last-seen-store-type';
|
import { ILastSeenStore } from './types/last-seen-store-type';
|
||||||
import { IFeatureToggleStore, IUnleashStores } from '../../../../lib/types';
|
import {
|
||||||
|
IFeatureToggleStore,
|
||||||
|
IFlagResolver,
|
||||||
|
IUnleashStores,
|
||||||
|
} from '../../../../lib/types';
|
||||||
|
|
||||||
export type LastSeenInput = {
|
export type LastSeenInput = {
|
||||||
featureName: string;
|
featureName: string;
|
||||||
@ -21,6 +24,8 @@ export class LastSeenService {
|
|||||||
|
|
||||||
private config: IUnleashConfig;
|
private config: IUnleashConfig;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
{
|
{
|
||||||
featureToggleStore,
|
featureToggleStore,
|
||||||
@ -33,6 +38,7 @@ export class LastSeenService {
|
|||||||
this.logger = config.getLogger(
|
this.logger = config.getLogger(
|
||||||
'/services/client-metrics/last-seen-service.ts',
|
'/services/client-metrics/last-seen-service.ts',
|
||||||
);
|
);
|
||||||
|
this.flagResolver = config.flagResolver;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +81,8 @@ export class LastSeenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cleanLastSeen() {
|
async cleanLastSeen() {
|
||||||
await this.lastSeenStore.cleanLastSeen();
|
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||||
|
await this.lastSeenStore.cleanLastSeen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { IUnleashConfig, IUnleashStores, IUnleashServices } from '../types';
|
||||||
IUnleashConfig,
|
|
||||||
IUnleashStores,
|
|
||||||
IUnleashServices,
|
|
||||||
IFlagResolver,
|
|
||||||
} from '../types';
|
|
||||||
import FeatureTypeService from './feature-type-service';
|
import FeatureTypeService from './feature-type-service';
|
||||||
import EventService from './event-service';
|
import EventService from './event-service';
|
||||||
import HealthService from './health-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 { InstanceStatsService } from '../features/instance-stats/instance-stats-service';
|
||||||
import { FavoritesService } from './favorites-service';
|
import { FavoritesService } from './favorites-service';
|
||||||
import MaintenanceService from './maintenance-service';
|
import MaintenanceService from './maintenance-service';
|
||||||
import {
|
|
||||||
hoursToMilliseconds,
|
|
||||||
minutesToMilliseconds,
|
|
||||||
secondsToMilliseconds,
|
|
||||||
} from 'date-fns';
|
|
||||||
import { AccountService } from './account-service';
|
import { AccountService } from './account-service';
|
||||||
import { SchedulerService } from './scheduler-service';
|
import { SchedulerService } from '../features/scheduler/scheduler-service';
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
import {
|
import {
|
||||||
createExportImportTogglesService,
|
createExportImportTogglesService,
|
||||||
@ -109,149 +99,6 @@ import {
|
|||||||
} from '../features/feature-search/createFeatureSearchService';
|
} from '../features/feature-search/createFeatureSearchService';
|
||||||
import { FeatureSearchService } from '../features/feature-search/feature-search-service';
|
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 = (
|
export const createServices = (
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -438,13 +285,11 @@ export const createServices = (
|
|||||||
db ? createGetProductionChanges(db) : createFakeGetProductionChanges(),
|
db ? createGetProductionChanges(db) : createFakeGetProductionChanges(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const schedulerService = new SchedulerService(config.getLogger);
|
const maintenanceService = new MaintenanceService(config, settingService);
|
||||||
|
|
||||||
const maintenanceService = new MaintenanceService(
|
const schedulerService = new SchedulerService(
|
||||||
stores,
|
config.getLogger,
|
||||||
config,
|
maintenanceService,
|
||||||
settingService,
|
|
||||||
schedulerService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventAnnouncerService = new EventAnnouncerService(stores, config);
|
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 MaintenanceService from './maintenance-service';
|
||||||
import { IUnleashStores } from '../types';
|
|
||||||
import SettingService from './setting-service';
|
import SettingService from './setting-service';
|
||||||
import { createTestConfig } from '../../test/config/test-config';
|
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 config = createTestConfig();
|
||||||
const schedulerService = new SchedulerService(config.getLogger);
|
const settingStore = new FakeSettingStore();
|
||||||
const maintenanceService = new MaintenanceService(
|
const settingService = new SettingService({ settingStore }, config, {
|
||||||
{} as IUnleashStores,
|
storeEvent() {},
|
||||||
config,
|
} as unknown as EventService);
|
||||||
{ insert() {} } as unknown as SettingService,
|
const maintenanceService = new MaintenanceService(config, settingService);
|
||||||
schedulerService,
|
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(
|
await maintenanceService.toggleMaintenanceMode(
|
||||||
@ -19,26 +42,10 @@ test('Maintenance on should pause scheduler', async () => {
|
|||||||
'irrelevant user',
|
'irrelevant user',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(schedulerService.getMode()).toBe('paused');
|
const job = jest.fn();
|
||||||
schedulerService.stop();
|
|
||||||
});
|
await schedulerService.schedule(job, 10, 'test-id');
|
||||||
|
|
||||||
test('Maintenance off should resume scheduler', async () => {
|
expect(job).toBeCalledTimes(0);
|
||||||
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');
|
|
||||||
schedulerService.stop();
|
schedulerService.stop();
|
||||||
});
|
});
|
||||||
|
@ -1,40 +1,20 @@
|
|||||||
import { IUnleashConfig, IUnleashStores } from '../types';
|
import { IUnleashConfig } from '../types';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { IPatStore } from '../types/stores/pat-store';
|
|
||||||
import { IEventStore } from '../types/stores/event-store';
|
|
||||||
import SettingService from './setting-service';
|
import SettingService from './setting-service';
|
||||||
import { maintenanceSettingsKey } from '../types/settings/maintenance-settings';
|
import { maintenanceSettingsKey } from '../types/settings/maintenance-settings';
|
||||||
import { MaintenanceSchema } from '../openapi/spec/maintenance-schema';
|
import { MaintenanceSchema } from '../openapi/spec/maintenance-schema';
|
||||||
import { SchedulerService } from './scheduler-service';
|
|
||||||
|
|
||||||
export default class MaintenanceService {
|
export default class MaintenanceService {
|
||||||
private config: IUnleashConfig;
|
private config: IUnleashConfig;
|
||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
private patStore: IPatStore;
|
|
||||||
|
|
||||||
private eventStore: IEventStore;
|
|
||||||
|
|
||||||
private settingService: SettingService;
|
private settingService: SettingService;
|
||||||
|
|
||||||
private schedulerService: SchedulerService;
|
constructor(config: IUnleashConfig, settingService: SettingService) {
|
||||||
|
|
||||||
constructor(
|
|
||||||
{
|
|
||||||
patStore,
|
|
||||||
eventStore,
|
|
||||||
}: Pick<IUnleashStores, 'patStore' | 'eventStore'>,
|
|
||||||
config: IUnleashConfig,
|
|
||||||
settingService: SettingService,
|
|
||||||
schedulerService: SchedulerService,
|
|
||||||
) {
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = config.getLogger('services/pat-service.ts');
|
this.logger = config.getLogger('services/pat-service.ts');
|
||||||
this.patStore = patStore;
|
|
||||||
this.eventStore = eventStore;
|
|
||||||
this.settingService = settingService;
|
this.settingService = settingService;
|
||||||
this.schedulerService = schedulerService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async isMaintenanceMode(): Promise<boolean> {
|
async isMaintenanceMode(): Promise<boolean> {
|
||||||
@ -56,11 +36,6 @@ export default class MaintenanceService {
|
|||||||
setting: MaintenanceSchema,
|
setting: MaintenanceSchema,
|
||||||
user: string,
|
user: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (setting.enabled) {
|
|
||||||
this.schedulerService.pause();
|
|
||||||
} else if (!this.config.disableScheduler) {
|
|
||||||
this.schedulerService.resume();
|
|
||||||
}
|
|
||||||
return this.settingService.insert(
|
return this.settingService.insert(
|
||||||
maintenanceSettingsKey,
|
maintenanceSettingsKey,
|
||||||
setting,
|
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(
|
await this.changeRequestSegmentUsageReadModel.getStrategiesUsedInActiveChangeRequests(
|
||||||
id,
|
id,
|
||||||
)
|
)
|
||||||
).filter((strategy) => !strategyIds.has(strategy.id));
|
).filter(
|
||||||
|
(strategy) => !('id' in strategy && strategyIds.has(strategy.id)),
|
||||||
|
);
|
||||||
|
|
||||||
return { strategies, changeRequestStrategies };
|
return { strategies, changeRequestStrategies };
|
||||||
}
|
}
|
||||||
|
@ -23,8 +23,6 @@ export type IFlagKey =
|
|||||||
| 'filterInvalidClientMetrics'
|
| 'filterInvalidClientMetrics'
|
||||||
| 'lastSeenByEnvironment'
|
| 'lastSeenByEnvironment'
|
||||||
| 'customRootRolesKillSwitch'
|
| 'customRootRolesKillSwitch'
|
||||||
| 'featureNamingPattern'
|
|
||||||
| 'doraMetrics'
|
|
||||||
| 'variantTypeNumber'
|
| 'variantTypeNumber'
|
||||||
| 'privateProjects'
|
| 'privateProjects'
|
||||||
| 'dependentFeatures'
|
| 'dependentFeatures'
|
||||||
@ -116,14 +114,6 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES_KILL_SWITCH,
|
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES_KILL_SWITCH,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
featureNamingPattern: parseEnvVarBoolean(
|
|
||||||
process.env.UNLEASH_EXPERIMENTAL_FEATURE_NAMING_PATTERN,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
doraMetrics: parseEnvVarBoolean(
|
|
||||||
process.env.UNLEASH_EXPERIMENTAL_DORA_METRICS,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
dependentFeatures: parseEnvVarBoolean(
|
dependentFeatures: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_DEPENDENT_FEATURES,
|
process.env.UNLEASH_EXPERIMENTAL_DEPENDENT_FEATURES,
|
||||||
false,
|
false,
|
||||||
|
@ -37,7 +37,7 @@ import { InstanceStatsService } from '../features/instance-stats/instance-stats-
|
|||||||
import { FavoritesService } from '../services/favorites-service';
|
import { FavoritesService } from '../services/favorites-service';
|
||||||
import MaintenanceService from '../services/maintenance-service';
|
import MaintenanceService from '../services/maintenance-service';
|
||||||
import { AccountService } from '../services/account-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 { Knex } from 'knex';
|
||||||
import {
|
import {
|
||||||
IExportService,
|
IExportService,
|
||||||
|
@ -38,8 +38,6 @@ process.nextTick(async () => {
|
|||||||
anonymiseEventLog: false,
|
anonymiseEventLog: false,
|
||||||
responseTimeWithAppNameKillSwitch: false,
|
responseTimeWithAppNameKillSwitch: false,
|
||||||
lastSeenByEnvironment: true,
|
lastSeenByEnvironment: true,
|
||||||
featureNamingPattern: true,
|
|
||||||
doraMetrics: true,
|
|
||||||
variantTypeNumber: true,
|
variantTypeNumber: true,
|
||||||
privateProjects: true,
|
privateProjects: true,
|
||||||
dependentFeatures: true,
|
dependentFeatures: true,
|
||||||
|
@ -21,7 +21,6 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
featureNamingPattern: true,
|
|
||||||
dependentFeatures: 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.
|
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.
|
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 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).
|
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