mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-27 01:19:00 +02:00
Merge remote-tracking branch 'origin/main' into feat/impact-metrics-frontend
This commit is contained in:
commit
73ac8fad8e
4
.github/workflows/core-feature-alert.yml
vendored
4
.github/workflows/core-feature-alert.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
run: echo "PR_CREATOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Post a notification about core feature changes if not already commented
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prCreator = process.env.PR_CREATOR;
|
||||
@ -45,7 +45,7 @@ jobs:
|
||||
console.log('Comment already exists, skipping.');
|
||||
}
|
||||
- name: Add reviewers to the PR if they are not the creator
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prCreator = process.env.PR_CREATOR;
|
||||
|
40
CHANGELOG.md
40
CHANGELOG.md
@ -2,6 +2,46 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [7.0.3] - 2025-06-17
|
||||
|
||||
## [7.0.2] - 2025-06-17
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct upgrade link ([#10138](https://github.com/Unleash/unleash/issues/10138))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Report hostedBy and licenseType ([#10141](https://github.com/Unleash/unleash/issues/10141))
|
||||
|
||||
- Translate impact metrics to prom format ([#10147](https://github.com/Unleash/unleash/issues/10147))
|
||||
|
||||
- Expose impact metrics ([#10151](https://github.com/Unleash/unleash/issues/10151))
|
||||
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Clean up flag overview redesign ([#10140](https://github.com/Unleash/unleash/issues/10140))
|
||||
|
||||
- Remove flag enterprise-payg ([#10139](https://github.com/Unleash/unleash/issues/10139))
|
||||
|
||||
- Added flag for CR approver emails ([#10144](https://github.com/Unleash/unleash/issues/10144))
|
||||
|
||||
- Add PSF to approved licenses list ([#10148](https://github.com/Unleash/unleash/issues/10148))
|
||||
|
||||
- Now expose IFeatureUsageInfo to override telemetry checking ([#10149](https://github.com/Unleash/unleash/issues/10149))
|
||||
|
||||
- Improve json diff view ([#10146](https://github.com/Unleash/unleash/issues/10146))
|
||||
|
||||
- Use logger instead of console.error ([#10150](https://github.com/Unleash/unleash/issues/10150))
|
||||
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate from make-fetch-happen to ky and use ky natively ([#10134](https://github.com/Unleash/unleash/issues/10134))
|
||||
|
||||
|
||||
## [7.0.1] - 2025-06-13
|
||||
|
||||
### Bug Fixes
|
||||
|
@ -64,10 +64,10 @@
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/react-table": "7.7.20",
|
||||
"@types/react-test-renderer": "18.3.1",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@uiw/codemirror-theme-duotone": "4.23.10",
|
||||
"@uiw/react-codemirror": "4.23.10",
|
||||
"@uiw/codemirror-theme-duotone": "4.23.13",
|
||||
"@uiw/react-codemirror": "4.23.13",
|
||||
"@unleash/proxy-client-react": "^5.0.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"cartesian": "^1.0.1",
|
||||
@ -114,7 +114,7 @@
|
||||
"react-table": "7.8.0",
|
||||
"react-test-renderer": "18.3.1",
|
||||
"sass": "1.85.1",
|
||||
"semver": "7.7.1",
|
||||
"semver": "7.7.2",
|
||||
"swr": "2.3.3",
|
||||
"tss-react": "4.9.15",
|
||||
"typescript": "5.8.3",
|
||||
@ -134,7 +134,7 @@
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"json5": "^2.2.2",
|
||||
"vite": "5.4.19",
|
||||
"semver": "7.7.1",
|
||||
"semver": "7.7.2",
|
||||
"ws": "^8.18.0",
|
||||
"@types/react": "18.3.18"
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import { useImperativeHandle } from 'react';
|
||||
import { useEffect, useImperativeHandle } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { styled } from '@mui/material';
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
@ -9,6 +9,7 @@ import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsLis
|
||||
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint';
|
||||
import { createEmptyConstraint } from '../../../../utils/createEmptyConstraint.ts';
|
||||
import { constraintId } from 'constants/constraintId.ts';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
export interface IEditableConstraintsListRef {
|
||||
addConstraint?: (contextName: string) => void;
|
||||
}
|
||||
@ -39,6 +40,17 @@ export const EditableConstraintsList = forwardRef<
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!constraints.every((constraint) => constraintId in constraint)) {
|
||||
setConstraints(
|
||||
constraints.map((constraint) => ({
|
||||
[constraintId]: uuidv4(),
|
||||
...constraint,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [constraints, setConstraints]);
|
||||
|
||||
const onDelete = (index: number) => {
|
||||
setConstraints(
|
||||
produce((draft) => {
|
||||
|
@ -23,6 +23,7 @@ interface IEventDiffProps {
|
||||
* @deprecated remove with flag improvedJsonDiff
|
||||
*/
|
||||
sort?: (a: IEventDiffResult, b: IEventDiffResult) => number;
|
||||
excludeKeys?: string[];
|
||||
}
|
||||
|
||||
const DiffStyles = styled('div')(({ theme }) => ({
|
||||
@ -37,7 +38,6 @@ const DiffStyles = styled('div')(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
marginLeft: '-10px',
|
||||
},
|
||||
},
|
||||
|
||||
@ -47,35 +47,65 @@ const DiffStyles = styled('div')(({ theme }) => ({
|
||||
content: '"+"',
|
||||
},
|
||||
},
|
||||
|
||||
'.deletion': {
|
||||
color: theme.palette.eventLog.diffSub,
|
||||
'::before': {
|
||||
content: '"-"',
|
||||
},
|
||||
},
|
||||
|
||||
'&[data-change-type="replacement"]': {
|
||||
':has(.addition)': {
|
||||
color: theme.palette.eventLog.diffAdd,
|
||||
},
|
||||
':has(.deletion)': {
|
||||
color: theme.palette.eventLog.diffSub,
|
||||
},
|
||||
'.addition::before, .deletion::before': {
|
||||
content: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
'.diff:not(:has(*))': {
|
||||
'::before': {
|
||||
content: '"(no changes)"',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const NewEventDiff: FC<IEventDiffProps> = ({ entry }) => {
|
||||
const ButtonIcon = styled('span')(({ theme }) => ({
|
||||
marginInlineEnd: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
const NewEventDiff: FC<IEventDiffProps> = ({ entry, excludeKeys }) => {
|
||||
const changeType = entry.preData && entry.data ? 'edit' : 'replacement';
|
||||
const showExpandButton = changeType === 'edit';
|
||||
const [full, setFull] = useState(false);
|
||||
const diffId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setFull(!full)}
|
||||
aria-controls={diffId}
|
||||
aria-expanded={full}
|
||||
>
|
||||
{full ? 'Show only changed properties' : 'Show all properties'}
|
||||
</Button>
|
||||
<DiffStyles id={diffId}>
|
||||
{showExpandButton ? (
|
||||
<Button
|
||||
onClick={() => setFull(!full)}
|
||||
aria-controls={diffId}
|
||||
aria-expanded={full}
|
||||
>
|
||||
<ButtonIcon aria-hidden>{full ? '-' : '+'}</ButtonIcon>
|
||||
{full
|
||||
? 'Show only changed properties'
|
||||
: 'Show all properties'}
|
||||
</Button>
|
||||
) : null}
|
||||
<DiffStyles data-change-type={changeType} id={diffId}>
|
||||
<JsonDiffComponent
|
||||
jsonA={(entry.preData ?? {}) as JsonValue}
|
||||
jsonB={(entry.data ?? {}) as JsonValue}
|
||||
jsonDiffOptions={{
|
||||
full: full,
|
||||
maxElisions: 2,
|
||||
excludeKeys: ['id', 'createdAt', 'updatedAt'],
|
||||
excludeKeys: excludeKeys,
|
||||
}}
|
||||
/>
|
||||
</DiffStyles>
|
||||
|
@ -72,6 +72,7 @@ export const useEventLogSearch = (
|
||||
createdBy: FilterItemParam,
|
||||
type: FilterItemParam,
|
||||
environment: FilterItemParam,
|
||||
id: StringParam,
|
||||
...extraParameters(logType),
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { createEmptyConstraint } from 'utils/createEmptyConstraint';
|
||||
import { fromIConstraint, toIConstraint } from './editable-constraint-type.ts';
|
||||
import { constraintId } from 'constants/constraintId';
|
||||
import type { IConstraint } from 'interfaces/strategy.ts';
|
||||
|
||||
test('mapping to and from retains the constraint id', () => {
|
||||
const constraint = createEmptyConstraint('context');
|
||||
|
||||
expect(toIConstraint(fromIConstraint(constraint))[constraintId]).toEqual(
|
||||
constraint[constraintId],
|
||||
);
|
||||
});
|
||||
|
||||
test('mapping to an editable constraint adds a constraint id if there is none', () => {
|
||||
const constraint: IConstraint = createEmptyConstraint('context');
|
||||
delete constraint[constraintId];
|
||||
|
||||
const editableConstraint = fromIConstraint(constraint);
|
||||
|
||||
expect(editableConstraint[constraintId]).toBeDefined();
|
||||
|
||||
const iConstraint = toIConstraint(editableConstraint);
|
||||
expect(iConstraint[constraintId]).toEqual(editableConstraint[constraintId]);
|
||||
});
|
||||
|
||||
test('mapping from an empty constraint removes redundant value / values', () => {
|
||||
const constraint = { ...createEmptyConstraint('context'), value: '' };
|
||||
expect(constraint).toHaveProperty('value');
|
||||
|
||||
const transformed = toIConstraint(fromIConstraint(constraint));
|
||||
expect(transformed).not.toHaveProperty('value');
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
import { constraintId } from 'constants/constraintId';
|
||||
import {
|
||||
type DateOperator,
|
||||
isDateOperator,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
isSemVerOperator,
|
||||
} from 'constants/operators';
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type EditableConstraintBase = Omit<
|
||||
IConstraint,
|
||||
@ -72,12 +74,14 @@ export const fromIConstraint = (
|
||||
const { value, values, operator, ...rest } = constraint;
|
||||
if (isSingleValueOperator(operator)) {
|
||||
return {
|
||||
[constraintId]: uuidv4(),
|
||||
...rest,
|
||||
operator,
|
||||
value: value ?? '',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
[constraintId]: uuidv4(),
|
||||
...rest,
|
||||
operator,
|
||||
values: new Set(values),
|
||||
@ -92,7 +96,7 @@ export const toIConstraint = (constraint: EditableConstraint): IConstraint => {
|
||||
const { values, ...rest } = constraint;
|
||||
return {
|
||||
...rest,
|
||||
values: Array.from(values),
|
||||
values: Array.from(constraint.values),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -40,11 +40,13 @@ test('calls onUpdate with new state', async () => {
|
||||
|
||||
// gets called by useEffect, so we need to wait for the next render.
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
contextName: 'context-field',
|
||||
operator: IN,
|
||||
values: [],
|
||||
});
|
||||
expect(onUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contextName: 'context-field',
|
||||
operator: IN,
|
||||
values: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -37,6 +37,7 @@ import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/Pro
|
||||
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm.tsx';
|
||||
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||
import { Limit } from 'component/common/Limit/Limit';
|
||||
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||
|
||||
const useStrategyLimit = (strategyCount: number) => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
@ -280,7 +281,7 @@ export const formatAddStrategyApiCode = (
|
||||
}
|
||||
|
||||
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
|
||||
const payload = JSON.stringify(strategy, undefined, 2);
|
||||
const payload = JSON.stringify(strategy, apiPayloadConstraintReplacer, 2);
|
||||
|
||||
return `curl --location --request POST '${url}' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
|
@ -35,6 +35,7 @@ import {
|
||||
getChangeRequestConflictCreatedDataFromScheduleData,
|
||||
} from './change-request-conflict-data.ts';
|
||||
import { constraintId } from 'constants/constraintId.ts';
|
||||
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||
|
||||
const useTitleTracking = () => {
|
||||
const [previousTitle, setPreviousTitle] = useState<string>('');
|
||||
@ -352,7 +353,11 @@ export const formatUpdateStrategyApiCode = (
|
||||
};
|
||||
|
||||
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
|
||||
const payload = JSON.stringify(sortedStrategy, undefined, 2);
|
||||
const payload = JSON.stringify(
|
||||
sortedStrategy,
|
||||
apiPayloadConstraintReplacer,
|
||||
2,
|
||||
);
|
||||
|
||||
return `curl --location --request PUT '${url}' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
|
@ -143,28 +143,6 @@ const StyledAlertBox = styled(Box)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledEnvironmentBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const EnvironmentIconBox = styled(Box)(({ theme }) => ({
|
||||
transform: 'scale(0.9)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const EnvironmentTypography = styled(Typography, {
|
||||
shouldForwardProp: (prop) => prop !== 'enabled',
|
||||
})<{ enabled: boolean }>(({ enabled }) => ({
|
||||
fontWeight: enabled ? 'bold' : 'normal',
|
||||
}));
|
||||
|
||||
const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({
|
||||
marginRight: theme.spacing(0.5),
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
width: '100px',
|
||||
}));
|
||||
@ -173,11 +151,11 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledConstraintSeparator = styled(ConstraintSeparator)(({ theme }) => ({
|
||||
const StyledConstraintSeparator = styled(ConstraintSeparator)({
|
||||
top: '-10px',
|
||||
left: '0',
|
||||
transform: 'translateY(0)',
|
||||
}));
|
||||
});
|
||||
|
||||
export const FeatureStrategyForm = ({
|
||||
projectId,
|
||||
|
@ -59,6 +59,7 @@ export const StyledProjectTitle = styled('h1')(({ theme }) => ({
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(2),
|
||||
overflow: 'hidden',
|
||||
lineHeight: 1.5,
|
||||
}));
|
||||
|
||||
export const StyledSeparator = styled('div')(({ theme }) => ({
|
||||
|
@ -9,7 +9,6 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import { BulkDisableDialog } from 'component/feature/FeatureToggleList/BulkDisableDialog';
|
||||
import { BulkEnableDialog } from 'component/feature/FeatureToggleList/BulkEnableDialog';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
interface IProjectFeaturesBatchActionsProps {
|
||||
selectedIds: string[];
|
||||
@ -72,32 +71,20 @@ export const ProjectFeaturesBatchActions: FC<
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.disableBulkToggle)}
|
||||
show={null}
|
||||
elseShow={
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='small'
|
||||
onClick={() => setShowBulkEnableDialog(true)}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.disableBulkToggle)}
|
||||
show={null}
|
||||
elseShow={
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='small'
|
||||
onClick={() => setShowBulkDisableDialog(true)}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='small'
|
||||
onClick={() => setShowBulkEnableDialog(true)}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='small'
|
||||
onClick={() => setShowBulkDisableDialog(true)}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
<ArchiveButton
|
||||
projectId={projectId}
|
||||
featureIds={selectedIds}
|
||||
|
@ -21,6 +21,7 @@ import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValues
|
||||
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
|
||||
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
|
||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
||||
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||
|
||||
interface ICreateSegmentProps {
|
||||
modal?: boolean;
|
||||
@ -61,7 +62,7 @@ export const CreateSegment = ({ modal }: ICreateSegmentProps) => {
|
||||
return `curl --location --request POST '${uiConfig.unleashUrl}/api/admin/segments' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
|
||||
--data-raw '${JSON.stringify(getSegmentPayload(), apiPayloadConstraintReplacer, 2)}'`;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
@ -24,15 +24,32 @@ import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentL
|
||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
|
||||
import type { ISegment } from 'interfaces/segment.ts';
|
||||
import { constraintId } from 'constants/constraintId.ts';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||
|
||||
interface IEditSegmentProps {
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
const addIdSymbolToConstraints = (segment?: ISegment): ISegment | undefined => {
|
||||
if (!segment) return;
|
||||
|
||||
const constraints = segment.constraints.map((constraint) => {
|
||||
return { ...constraint, [constraintId]: uuidv4() };
|
||||
});
|
||||
|
||||
return { ...segment, constraints };
|
||||
};
|
||||
|
||||
export const EditSegment = ({ modal }: IEditSegmentProps) => {
|
||||
const projectId = useOptionalPathParam('projectId');
|
||||
const segmentId = useRequiredPathParam('segmentId');
|
||||
const { segment } = useSegment(Number(segmentId));
|
||||
const { segment: segmentWithoutConstraintIds } = useSegment(
|
||||
Number(segmentId),
|
||||
);
|
||||
const segment = addIdSymbolToConstraints(segmentWithoutConstraintIds);
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const navigate = useNavigate();
|
||||
@ -72,7 +89,7 @@ export const EditSegment = ({ modal }: IEditSegmentProps) => {
|
||||
}/api/admin/segments/${segmentId}' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
|
||||
--data-raw '${JSON.stringify(getSegmentPayload(), apiPayloadConstraintReplacer, 2)}'`;
|
||||
};
|
||||
|
||||
const highestPermissionChangeRequestEnv =
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy';
|
||||
import { SegmentFormStepOne } from './SegmentFormStepOne.tsx';
|
||||
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
|
||||
import type React from 'react';
|
||||
@ -14,7 +14,7 @@ interface ISegmentProps {
|
||||
name: string;
|
||||
description: string;
|
||||
project?: string;
|
||||
constraints: IConstraint[];
|
||||
constraints: IConstraintWithId[];
|
||||
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProject: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
|
@ -3,12 +3,12 @@ import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
CREATE_SEGMENT,
|
||||
UPDATE_PROJECT_SEGMENT,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import type { IConstraintWithId } from 'interfaces/strategy.ts';
|
||||
|
||||
const server = testServerSetup();
|
||||
|
||||
@ -26,7 +26,7 @@ const setupRoutes = () => {
|
||||
|
||||
const defaultProps = {
|
||||
project: undefined,
|
||||
constraints: [] as IConstraint[],
|
||||
constraints: [] as IConstraintWithId[],
|
||||
setConstraints: vi.fn(),
|
||||
setCurrentStep: vi.fn(),
|
||||
mode: 'create' as const,
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
UPDATE_SEGMENT,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EditableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
|
||||
import type { IEditableConstraintsListRef } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
|
||||
@ -33,7 +33,7 @@ import { GO_BACK } from 'constants/navigate';
|
||||
|
||||
interface ISegmentFormPartTwoProps {
|
||||
project?: string;
|
||||
constraints: IConstraint[];
|
||||
constraints: IConstraintWithId[];
|
||||
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
|
||||
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
|
||||
mode: SegmentFormMode;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { constraintId } from 'constants/constraintId';
|
||||
|
||||
export const useSegmentForm = (
|
||||
initialName = '',
|
||||
@ -11,8 +13,13 @@ export const useSegmentForm = (
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [project, setProject] = useState<string | undefined>(initialProject);
|
||||
const [constraints, setConstraints] =
|
||||
useState<IConstraint[]>(initialConstraints);
|
||||
const initialConstraintsWithId = initialConstraints.map((constraint) => ({
|
||||
[constraintId]: uuidv4(),
|
||||
...constraint,
|
||||
}));
|
||||
const [constraints, setConstraints] = useState<IConstraintWithId[]>(
|
||||
initialConstraintsWithId,
|
||||
);
|
||||
const [errors, setErrors] = useState({});
|
||||
const nameError = useSegmentValidation(name, initialName);
|
||||
|
||||
@ -29,7 +36,7 @@ export const useSegmentForm = (
|
||||
}, [initialProject]);
|
||||
|
||||
useEffect(() => {
|
||||
setConstraints(initialConstraints);
|
||||
setConstraints(initialConstraintsWithId);
|
||||
// eslint-disable-next-line
|
||||
}, [JSON.stringify(initialConstraints)]);
|
||||
|
||||
@ -61,7 +68,9 @@ export const useSegmentForm = (
|
||||
project,
|
||||
setProject,
|
||||
constraints,
|
||||
setConstraints,
|
||||
setConstraints: setConstraints as React.Dispatch<
|
||||
React.SetStateAction<IConstraint[]>
|
||||
>,
|
||||
getSegmentPayload,
|
||||
clearErrors,
|
||||
errors,
|
||||
|
@ -68,6 +68,10 @@ export interface IConstraint {
|
||||
[constraintId]?: string;
|
||||
}
|
||||
|
||||
export interface IConstraintWithId extends IConstraint {
|
||||
[constraintId]: string;
|
||||
}
|
||||
|
||||
export interface IFeatureStrategySortOrder {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
|
@ -59,7 +59,6 @@ export type UiFlags = {
|
||||
personalAccessTokensKillSwitch?: boolean;
|
||||
demo?: boolean;
|
||||
googleAuthEnabled?: boolean;
|
||||
disableBulkToggle?: boolean;
|
||||
advancedPlayground?: boolean;
|
||||
strategyVariant?: boolean;
|
||||
doraMetrics?: boolean;
|
||||
|
57
frontend/src/utils/api-payload-constraint-replacer.test.ts
Normal file
57
frontend/src/utils/api-payload-constraint-replacer.test.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import { serializeConstraint } from './api-payload-constraint-replacer.ts';
|
||||
import { constraintId } from 'constants/constraintId';
|
||||
|
||||
test('keys are ordered in the expected order', () => {
|
||||
const input: IConstraint = {
|
||||
values: ['something'],
|
||||
inverted: true,
|
||||
operator: 'STR_CONTAINS',
|
||||
contextName: 'context',
|
||||
caseInsensitive: true,
|
||||
};
|
||||
|
||||
const output = serializeConstraint(input);
|
||||
|
||||
expect(Object.entries(output)).toStrictEqual([
|
||||
['contextName', 'context'],
|
||||
['operator', 'STR_CONTAINS'],
|
||||
['values', ['something']],
|
||||
['caseInsensitive', true],
|
||||
['inverted', true],
|
||||
]);
|
||||
});
|
||||
|
||||
test('only value OR values is present, not both', () => {
|
||||
const input: IConstraint = {
|
||||
value: 'something',
|
||||
values: ['something else'],
|
||||
inverted: true,
|
||||
operator: 'IN',
|
||||
contextName: 'context',
|
||||
caseInsensitive: true,
|
||||
};
|
||||
|
||||
const noValue = serializeConstraint(input);
|
||||
expect(noValue.values).toStrictEqual(['something else']);
|
||||
expect(noValue).not.toHaveProperty('value');
|
||||
|
||||
const noValues = serializeConstraint({
|
||||
...input,
|
||||
operator: 'SEMVER_EQ',
|
||||
});
|
||||
expect(noValues.value).toStrictEqual('something');
|
||||
expect(noValues).not.toHaveProperty('values');
|
||||
});
|
||||
|
||||
test('constraint id is not included', () => {
|
||||
const input: IConstraint = {
|
||||
[constraintId]: 'constraint-id',
|
||||
value: 'something',
|
||||
operator: 'IN',
|
||||
contextName: 'context',
|
||||
};
|
||||
|
||||
const output = serializeConstraint(input);
|
||||
expect(constraintId in output).toBeFalsy();
|
||||
});
|
39
frontend/src/utils/api-payload-constraint-replacer.ts
Normal file
39
frontend/src/utils/api-payload-constraint-replacer.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { isSingleValueOperator } from 'constants/operators';
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
|
||||
export const serializeConstraint = ({
|
||||
value,
|
||||
values,
|
||||
inverted,
|
||||
operator,
|
||||
contextName,
|
||||
caseInsensitive,
|
||||
}: IConstraint): IConstraint => {
|
||||
const makeConstraint = (
|
||||
valueProp: { value: string } | { values: string[] },
|
||||
): IConstraint => {
|
||||
return {
|
||||
contextName,
|
||||
operator,
|
||||
...valueProp,
|
||||
caseInsensitive,
|
||||
inverted,
|
||||
};
|
||||
};
|
||||
|
||||
if (isSingleValueOperator(operator)) {
|
||||
return makeConstraint({ value: value || '' });
|
||||
}
|
||||
|
||||
return makeConstraint({ values: values || [] });
|
||||
};
|
||||
|
||||
export const apiPayloadConstraintReplacer = (key: string, value: any) => {
|
||||
if (key !== 'constraints' || !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const orderedConstraints = (value as IConstraint[]).map(
|
||||
serializeConstraint,
|
||||
);
|
||||
return orderedConstraints;
|
||||
};
|
@ -1,16 +1,15 @@
|
||||
import { constraintId } from 'constants/constraintId';
|
||||
import { dateOperators } from 'constants/operators';
|
||||
import type { IConstraint } from 'interfaces/strategy';
|
||||
import { oneOf } from 'utils/oneOf';
|
||||
import { isDateOperator } from 'constants/operators';
|
||||
import type { IConstraintWithId } from 'interfaces/strategy';
|
||||
import { operatorsForContext } from 'utils/operatorsForContext';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const createEmptyConstraint = (contextName: string): IConstraint => {
|
||||
export const createEmptyConstraint = (
|
||||
contextName: string,
|
||||
): IConstraintWithId => {
|
||||
const operator = operatorsForContext(contextName)[0];
|
||||
|
||||
const value = oneOf(dateOperators, operator)
|
||||
? new Date().toISOString()
|
||||
: '';
|
||||
const value = isDateOperator(operator) ? new Date().toISOString() : '';
|
||||
|
||||
return {
|
||||
contextName,
|
||||
|
@ -2877,14 +2877,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-virtual@npm:^3.11.3":
|
||||
version: 3.13.9
|
||||
resolution: "@tanstack/react-virtual@npm:3.13.9"
|
||||
version: 3.13.10
|
||||
resolution: "@tanstack/react-virtual@npm:3.13.10"
|
||||
dependencies:
|
||||
"@tanstack/virtual-core": "npm:3.13.9"
|
||||
"@tanstack/virtual-core": "npm:3.13.10"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/aa05fb24e30686516e74ccdec94a83d195615a4f29bc866a53ee6b0107e466c6d6e3e947b3fb0613b907b0f982d74b00367868cf6b1ac956562d0e7c24d6764b
|
||||
checksum: 10c0/587ef4db703cc9d870ee68b3f1471118fe69920e8f59cf627a359d518331c6684069fb3e159cd35ac458ea60a829b66953b774314833e1cb10b365255791559e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -2895,10 +2895,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/virtual-core@npm:3.13.9":
|
||||
version: 3.13.9
|
||||
resolution: "@tanstack/virtual-core@npm:3.13.9"
|
||||
checksum: 10c0/6e9526a9d52f8ddc54af8a1dc7380814b10ab38d8a4265e362a5b69c3132097ace51496d4206fe8aa90e33129aaf1a177c93d7ed018b5564b78e057fc9cdb48d
|
||||
"@tanstack/virtual-core@npm:3.13.10":
|
||||
version: 3.13.10
|
||||
resolution: "@tanstack/virtual-core@npm:3.13.10"
|
||||
checksum: 10c0/ecfe56cc37db088416abb1f1b9641cc7b05b387bb532e4fe42f30a5477e55fdbb724f54e8dc5c3d8b380a5e9f80cc11426519825f8ecbcb6ca717639ba702cc8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3199,20 +3199,20 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:*":
|
||||
version: 22.13.10
|
||||
resolution: "@types/node@npm:22.13.10"
|
||||
version: 24.0.3
|
||||
resolution: "@types/node@npm:24.0.3"
|
||||
dependencies:
|
||||
undici-types: "npm:~6.20.0"
|
||||
checksum: 10c0/a3865f9503d6f718002374f7b87efaadfae62faa499c1a33b12c527cfb9fd86f733e1a1b026b80c5a0e4a965701174bc3305595a7d36078aa1abcf09daa5dee9
|
||||
undici-types: "npm:~7.8.0"
|
||||
checksum: 10c0/9c3c4e87600d1cf11e291c2fd4bfd806a615455463c30a0ef6dc9c801b3423344d9b82b8084e3ccabce485a7421ebb61a66e9676181bd7d9aea4759998a120d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^22.0.0":
|
||||
version: 22.15.18
|
||||
resolution: "@types/node@npm:22.15.18"
|
||||
version: 22.15.32
|
||||
resolution: "@types/node@npm:22.15.32"
|
||||
dependencies:
|
||||
undici-types: "npm:~6.21.0"
|
||||
checksum: 10c0/e23178c568e2dc6b93b6aa3b8dfb45f9556e527918c947fe7406a4c92d2184c7396558912400c3b1b8d0fa952ec63819aca2b8e4d3545455fc6f1e9623e09ca6
|
||||
checksum: 10c0/63a2fa52adf1134d1b3bee8b1862d4b8e4550fffc190551068d3d41a41d9e5c0c8f1cb81faa18767b260637360f662115c26c5e4e7718868ead40c4a57cbc0e3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3313,10 +3313,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/semver@npm:7.5.8":
|
||||
version: 7.5.8
|
||||
resolution: "@types/semver@npm:7.5.8"
|
||||
checksum: 10c0/8663ff927234d1c5fcc04b33062cb2b9fcfbe0f5f351ed26c4d1e1581657deebd506b41ff7fdf89e787e3d33ce05854bc01686379b89e9c49b564c4cfa988efa
|
||||
"@types/semver@npm:7.7.0":
|
||||
version: 7.7.0
|
||||
resolution: "@types/semver@npm:7.7.0"
|
||||
checksum: 10c0/6b5f65f647474338abbd6ee91a6bbab434662ddb8fe39464edcbcfc96484d388baad9eb506dff217b6fc1727a88894930eb1f308617161ac0f376fe06be4e1ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3408,9 +3408,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uiw/codemirror-extensions-basic-setup@npm:4.23.10":
|
||||
version: 4.23.10
|
||||
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10"
|
||||
"@uiw/codemirror-extensions-basic-setup@npm:4.23.13":
|
||||
version: 4.23.13
|
||||
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.13"
|
||||
dependencies:
|
||||
"@codemirror/autocomplete": "npm:^6.0.0"
|
||||
"@codemirror/commands": "npm:^6.0.0"
|
||||
@ -3427,22 +3427,22 @@ __metadata:
|
||||
"@codemirror/search": ">=6.0.0"
|
||||
"@codemirror/state": ">=6.0.0"
|
||||
"@codemirror/view": ">=6.0.0"
|
||||
checksum: 10c0/64c233857b1bf878bf630297b1d4b3df14e13761ff38ceccf7a1fa21d521be288311333b7cbff927889f9a899848f4fccefd975ea5fa8d626ea4aef088f13ee8
|
||||
checksum: 10c0/db0d1c60c8b13f69aa02b969618d3b9f5aafab23af3c8d9be9a88016aff94de1e45a1c850daf7740d265a3e5452a916112f73e5c22857346a7002c243c934159
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uiw/codemirror-theme-duotone@npm:4.23.10":
|
||||
version: 4.23.10
|
||||
resolution: "@uiw/codemirror-theme-duotone@npm:4.23.10"
|
||||
"@uiw/codemirror-theme-duotone@npm:4.23.13":
|
||||
version: 4.23.13
|
||||
resolution: "@uiw/codemirror-theme-duotone@npm:4.23.13"
|
||||
dependencies:
|
||||
"@uiw/codemirror-themes": "npm:4.23.10"
|
||||
checksum: 10c0/058fe2ec927fb7d9fdd7a040c1fa52ed52b2b786e2917ae981a687b1aafeac26022900aac625c2018288c9bd7ab9e056854d6628eaaea46a4251b0022693df7c
|
||||
"@uiw/codemirror-themes": "npm:4.23.13"
|
||||
checksum: 10c0/3c0994d7731fdad1537d66f331b3bc1f4308e9388001b3a1d168c8e4b751f28661652533b0c830b3f30456132572686a298af0672f3f36872a08fdc1cf9b1e47
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uiw/codemirror-themes@npm:4.23.10":
|
||||
version: 4.23.10
|
||||
resolution: "@uiw/codemirror-themes@npm:4.23.10"
|
||||
"@uiw/codemirror-themes@npm:4.23.13":
|
||||
version: 4.23.13
|
||||
resolution: "@uiw/codemirror-themes@npm:4.23.13"
|
||||
dependencies:
|
||||
"@codemirror/language": "npm:^6.0.0"
|
||||
"@codemirror/state": "npm:^6.0.0"
|
||||
@ -3451,19 +3451,19 @@ __metadata:
|
||||
"@codemirror/language": ">=6.0.0"
|
||||
"@codemirror/state": ">=6.0.0"
|
||||
"@codemirror/view": ">=6.0.0"
|
||||
checksum: 10c0/29f980789a535ae021ca8d8bec3ecee4dda8cb11c5451729c04481310cc04cd7c00ddb3011137f5cc305d565bc0a3464ebd88fdde9359e296d8f0a6cf6477811
|
||||
checksum: 10c0/4e3bc3f12681727d41ae3c60c17fab5be267cb2675ace8ce6c56c66d6453715bf6d00c9b356d27df70f2104b0eaeda8c734faf2f05b38af05e7c5b2306f2a182
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uiw/react-codemirror@npm:4.23.10":
|
||||
version: 4.23.10
|
||||
resolution: "@uiw/react-codemirror@npm:4.23.10"
|
||||
"@uiw/react-codemirror@npm:4.23.13":
|
||||
version: 4.23.13
|
||||
resolution: "@uiw/react-codemirror@npm:4.23.13"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.18.6"
|
||||
"@codemirror/commands": "npm:^6.1.0"
|
||||
"@codemirror/state": "npm:^6.1.1"
|
||||
"@codemirror/theme-one-dark": "npm:^6.0.0"
|
||||
"@uiw/codemirror-extensions-basic-setup": "npm:4.23.10"
|
||||
"@uiw/codemirror-extensions-basic-setup": "npm:4.23.13"
|
||||
codemirror: "npm:^6.0.0"
|
||||
peerDependencies:
|
||||
"@babel/runtime": ">=7.11.0"
|
||||
@ -3473,7 +3473,7 @@ __metadata:
|
||||
codemirror: ">=6.0.0"
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 10c0/cbcb928c84df2a6a03fd7789a3db317f6358961fd96197d2a3edb3ffbb00deff1cfa05fa1494c849393784bb89ae3f41425982f609f73bad0855a5e6522c328d
|
||||
checksum: 10c0/95e79e50fa1a28cbfaf982def6ff82c5ecf18fe7eca077b4bc4cba1d43580541fcb1c38effbd299acff97f77372d8bc910642adc3d6e610c5125a9f0b85f379e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -8548,14 +8548,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-activity-calendar@npm:^2.7.8":
|
||||
version: 2.7.8
|
||||
resolution: "react-activity-calendar@npm:2.7.8"
|
||||
"react-activity-calendar@npm:^2.7.11":
|
||||
version: 2.7.12
|
||||
resolution: "react-activity-calendar@npm:2.7.12"
|
||||
dependencies:
|
||||
date-fns: "npm:^4.1.0"
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/e18d17cadc65480a8fa1df390598d7a4741a211b164091ca85fd2a21673fa667a5921f7b856e4ed92c2e8dedfb961426f163e517ea37336f2c3afb987740620d
|
||||
checksum: 10c0/6e329c18d37e05c9f26efee3af229e9289744d9f6bac03a08b4911b62786a410d0f7cfe58ace1e52706da4adc250e82906a77ef6d2665e52d1e271edcdd8b60e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -8630,14 +8630,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-error-boundary@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "react-error-boundary@npm:5.0.0"
|
||||
"react-error-boundary@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "react-error-boundary@npm:6.0.0"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.12.5"
|
||||
peerDependencies:
|
||||
react: ">=16.13.1"
|
||||
checksum: 10c0/38da5e7e81016a4750d3b090e3c740c2c1125c0bb9de14e1ab92ee3b5190d34517c199935302718a24aa35d3f89081412b3444edc23f63729bde2e862a2fbfec
|
||||
checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -8665,14 +8665,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"react-github-calendar@npm:^4.5.1":
|
||||
version: 4.5.6
|
||||
resolution: "react-github-calendar@npm:4.5.6"
|
||||
version: 4.5.7
|
||||
resolution: "react-github-calendar@npm:4.5.7"
|
||||
dependencies:
|
||||
react-activity-calendar: "npm:^2.7.8"
|
||||
react-error-boundary: "npm:^5.0.0"
|
||||
react-activity-calendar: "npm:^2.7.11"
|
||||
react-error-boundary: "npm:^6.0.0"
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/74e995a528a3cf2a4ac4634415b614a0f6ad170f066d2d58142f15b815e8855de8b2ecf426ea054dd5d29143185dedc9a4ec7a8fa4dea4cbad6b7ef0f4411b5a
|
||||
checksum: 10c0/a274776d8f6547d342573a42618196f436d4e555a5eb84b7b43fda0350a5174959ae74bc9d230c8cdca1b8982e4f586583200292126d4aa05cb1e3d350b579d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -9241,12 +9241,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver@npm:7.7.1":
|
||||
version: 7.7.1
|
||||
resolution: "semver@npm:7.7.1"
|
||||
"semver@npm:7.7.2":
|
||||
version: 7.7.2
|
||||
resolution: "semver@npm:7.7.2"
|
||||
bin:
|
||||
semver: bin/semver.js
|
||||
checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958
|
||||
checksum: 10c0/aca305edfbf2383c22571cb7714f48cadc7ac95371b4b52362fb8eeffdfbc0de0669368b82b2b15978f8848f01d7114da65697e56cd8c37b0dab8c58e543f9ea
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -10221,13 +10221,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~6.20.0":
|
||||
version: 6.20.0
|
||||
resolution: "undici-types@npm:6.20.0"
|
||||
checksum: 10c0/68e659a98898d6a836a9a59e6adf14a5d799707f5ea629433e025ac90d239f75e408e2e5ff086afc3cace26f8b26ee52155293564593fbb4a2f666af57fc59bf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~6.21.0":
|
||||
version: 6.21.0
|
||||
resolution: "undici-types@npm:6.21.0"
|
||||
@ -10235,6 +10228,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~7.8.0":
|
||||
version: 7.8.0
|
||||
resolution: "undici-types@npm:7.8.0"
|
||||
checksum: 10c0/9d9d246d1dc32f318d46116efe3cfca5a72d4f16828febc1918d94e58f6ffcf39c158aa28bf5b4fc52f410446bc7858f35151367bd7a49f21746cab6497b709b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unified@npm:^10.0.0":
|
||||
version: 10.1.2
|
||||
resolution: "unified@npm:10.1.2"
|
||||
@ -10369,10 +10369,10 @@ __metadata:
|
||||
"@types/react-router-dom": "npm:5.3.3"
|
||||
"@types/react-table": "npm:7.7.20"
|
||||
"@types/react-test-renderer": "npm:18.3.1"
|
||||
"@types/semver": "npm:7.5.8"
|
||||
"@types/semver": "npm:7.7.0"
|
||||
"@types/uuid": "npm:^9.0.0"
|
||||
"@uiw/codemirror-theme-duotone": "npm:4.23.10"
|
||||
"@uiw/react-codemirror": "npm:4.23.10"
|
||||
"@uiw/codemirror-theme-duotone": "npm:4.23.13"
|
||||
"@uiw/react-codemirror": "npm:4.23.13"
|
||||
"@unleash/proxy-client-react": "npm:^5.0.0"
|
||||
"@vitejs/plugin-react": "npm:4.3.4"
|
||||
cartesian: "npm:^1.0.1"
|
||||
@ -10422,7 +10422,7 @@ __metadata:
|
||||
react-table: "npm:7.8.0"
|
||||
react-test-renderer: "npm:18.3.1"
|
||||
sass: "npm:1.85.1"
|
||||
semver: "npm:7.7.1"
|
||||
semver: "npm:7.7.2"
|
||||
swr: "npm:2.3.3"
|
||||
tss-react: "npm:4.9.15"
|
||||
typescript: "npm:5.8.3"
|
||||
|
18
package.json
18
package.json
@ -2,7 +2,7 @@
|
||||
"name": "unleash-server",
|
||||
"type": "module",
|
||||
"description": "Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.",
|
||||
"version": "7.0.1",
|
||||
"version": "7.0.3",
|
||||
"keywords": [
|
||||
"unleash",
|
||||
"feature flag",
|
||||
@ -129,7 +129,7 @@
|
||||
"ts-toolbelt": "^9.6.0",
|
||||
"type-is": "^1.6.18",
|
||||
"ulidx": "^2.4.1",
|
||||
"unleash-client": "^6.6.0",
|
||||
"unleash-client": "^6.7.0-beta.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -141,25 +141,25 @@
|
||||
"@swc/core": "1.11.31",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "1.18.1",
|
||||
"@types/express": "4.17.23",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/faker": "5.5.9",
|
||||
"@types/hash-sum": "^1.0.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/lodash.groupby": "4.6.9",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/memoizee": "0.4.11",
|
||||
"@types/memoizee": "0.4.12",
|
||||
"@types/mime": "4.0.0",
|
||||
"@types/murmurhash3js": "^3.0.7",
|
||||
"@types/mustache": "^4.2.5",
|
||||
"@types/node": "22.15.18",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/owasp-password-strength-test": "1.3.2",
|
||||
"@types/pg": "8.15.2",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/pg": "8.15.4",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/slug": "^5.0.8",
|
||||
"@types/stoppable": "1.1.3",
|
||||
"@types/supertest": "6.0.2",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/type-is": "1.6.7",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@vitest/coverage-v8": "^3.1.3",
|
||||
@ -167,7 +167,7 @@
|
||||
"concurrently": "^9.0.0",
|
||||
"copyfiles": "2.4.1",
|
||||
"coveralls": "^3.1.1",
|
||||
"del-cli": "5.1.0",
|
||||
"del-cli": "6.0.0",
|
||||
"faker": "5.5.3",
|
||||
"fast-check": "3.23.2",
|
||||
"fetch-mock": "^12.0.0",
|
||||
|
@ -11,7 +11,7 @@ services:
|
||||
- 5432:5432
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:9.3
|
||||
image: dpage/pgadmin4:9.4
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: 'admin@admin.com'
|
||||
PGADMIN_DEFAULT_PASSWORD: 'admin'
|
||||
|
@ -101,6 +101,7 @@ exports[`should create default config 1`] = `
|
||||
"preHook": undefined,
|
||||
"preRouterHook": undefined,
|
||||
"prometheusApi": undefined,
|
||||
"prometheusImpactMetricsApi": undefined,
|
||||
"publicFolder": undefined,
|
||||
"rateLimiting": {
|
||||
"callSignalEndpointMaxPerSecond": 1,
|
||||
|
@ -773,6 +773,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
||||
defaultDaysToBeConsideredInactive,
|
||||
);
|
||||
|
||||
const prometheusImpactMetricsApi =
|
||||
options.prometheusImpactMetricsApi ||
|
||||
process.env.PROMETHEUS_IMPACT_METRICS_API;
|
||||
|
||||
return {
|
||||
db,
|
||||
session,
|
||||
@ -804,6 +808,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
||||
clientFeatureCaching,
|
||||
accessControlMaxAge,
|
||||
prometheusApi,
|
||||
prometheusImpactMetricsApi,
|
||||
publicFolder: options.publicFolder,
|
||||
disableScheduler: options.disableScheduler,
|
||||
isEnterprise: isEnterprise,
|
||||
|
@ -71,35 +71,6 @@ export default class ClientInstanceStore implements IClientInstanceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* `bulkUpsert` is beeing used instead. remove with `lastSeenBulkQuery` flag
|
||||
*/
|
||||
async setLastSeen({
|
||||
appName,
|
||||
instanceId,
|
||||
environment,
|
||||
clientIp,
|
||||
}: INewClientInstance): Promise<void> {
|
||||
const stopTimer = this.metricTimer('setLastSeen');
|
||||
|
||||
await this.db(TABLE)
|
||||
.insert({
|
||||
app_name: appName,
|
||||
instance_id: instanceId,
|
||||
environment,
|
||||
last_seen: new Date(),
|
||||
client_ip: clientIp,
|
||||
})
|
||||
.onConflict(['app_name', 'instance_id', 'environment'])
|
||||
.merge({
|
||||
last_seen: new Date(),
|
||||
client_ip: clientIp,
|
||||
});
|
||||
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
async bulkUpsert(instances: INewClientInstance[]): Promise<void> {
|
||||
const stopTimer = this.metricTimer('bulkUpsert');
|
||||
|
||||
|
@ -222,7 +222,7 @@ export default class EventService {
|
||||
if (parsed) queryParams.push(parsed);
|
||||
}
|
||||
|
||||
['project', 'type', 'environment'].forEach((field) => {
|
||||
['project', 'type', 'environment', 'id'].forEach((field) => {
|
||||
if (params[field]) {
|
||||
const parsed = parseSearchOperatorValue(field, params[field]);
|
||||
if (parsed) queryParams.push(parsed);
|
||||
|
@ -918,11 +918,6 @@ export default class ProjectFeaturesController extends Controller {
|
||||
const { shouldActivateDisabledStrategies } = req.query;
|
||||
const { features } = req.body;
|
||||
|
||||
if (this.flagResolver.isEnabled('disableBulkToggle')) {
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transactionalFeatureToggleService.transactional((service) =>
|
||||
service.bulkUpdateEnabled(
|
||||
projectId,
|
||||
@ -950,11 +945,6 @@ export default class ProjectFeaturesController extends Controller {
|
||||
const { shouldActivateDisabledStrategies } = req.query;
|
||||
const { features } = req.body;
|
||||
|
||||
if (this.flagResolver.isEnabled('disableBulkToggle')) {
|
||||
res.status(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transactionalFeatureToggleService.transactional((service) =>
|
||||
service.bulkUpdateEnabled(
|
||||
projectId,
|
||||
|
@ -6,7 +6,7 @@ import type {
|
||||
IClientMetricsEnv,
|
||||
IClientMetricsStoreV2,
|
||||
} from './client-metrics-store-v2-type.js';
|
||||
import { clientMetricsSchema } from '../shared/schema.js';
|
||||
import { clientMetricsSchema, impactMetricsSchema } from '../shared/schema.js';
|
||||
import { compareAsc, secondsToMilliseconds } from 'date-fns';
|
||||
import {
|
||||
CLIENT_METRICS,
|
||||
@ -30,6 +30,11 @@ import {
|
||||
MAX_UNKNOWN_FLAGS,
|
||||
type UnknownFlagsService,
|
||||
} from '../unknown-flags/unknown-flags-service.js';
|
||||
import {
|
||||
type Metric,
|
||||
MetricsTranslator,
|
||||
} from '../impact/metrics-translator.js';
|
||||
import { impactRegister } from '../impact/impact-register.js';
|
||||
|
||||
export default class ClientMetricsServiceV2 {
|
||||
private config: IUnleashConfig;
|
||||
@ -46,6 +51,8 @@ export default class ClientMetricsServiceV2 {
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private impactMetricsTranslator: MetricsTranslator;
|
||||
|
||||
private cachedFeatureNames: () => Promise<string[]>;
|
||||
|
||||
constructor(
|
||||
@ -69,6 +76,7 @@ export default class ClientMetricsServiceV2 {
|
||||
maxAge: secondsToMilliseconds(10),
|
||||
},
|
||||
);
|
||||
this.impactMetricsTranslator = new MetricsTranslator(impactRegister);
|
||||
}
|
||||
|
||||
async clearMetrics(hoursAgo: number) {
|
||||
@ -187,6 +195,11 @@ export default class ClientMetricsServiceV2 {
|
||||
this.lastSeenService.updateLastSeen(metrics);
|
||||
}
|
||||
|
||||
async registerImpactMetrics(impactMetrics: Metric[]) {
|
||||
const value = await impactMetricsSchema.validateAsync(impactMetrics);
|
||||
this.impactMetricsTranslator.translateMetrics(value);
|
||||
}
|
||||
|
||||
async registerClientMetrics(
|
||||
data: ClientMetricsSchema,
|
||||
clientIp: string,
|
||||
|
103
src/lib/features/metrics/impact/impact-metrics.e2e.test.ts
Normal file
103
src/lib/features/metrics/impact/impact-metrics.e2e.test.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
type IUnleashTest,
|
||||
setupAppWithCustomConfig,
|
||||
} from '../../../../test/e2e/helpers/test-helper.js';
|
||||
import dbInit, {
|
||||
type ITestDb,
|
||||
} from '../../../../test/e2e/helpers/database-init.js';
|
||||
import getLogger from '../../../../test/fixtures/no-logger.js';
|
||||
import type { Metric } from './metrics-translator.js';
|
||||
|
||||
let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
|
||||
const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) =>
|
||||
app.request
|
||||
.post('/api/client/metrics')
|
||||
.send({
|
||||
appName: 'impact-metrics-app',
|
||||
instanceId: 'instance-id',
|
||||
bucket: {
|
||||
start: Date.now(),
|
||||
stop: Date.now(),
|
||||
toggles: {},
|
||||
},
|
||||
impactMetrics,
|
||||
})
|
||||
.expect(status);
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('impact_metrics', getLogger);
|
||||
app = await setupAppWithCustomConfig(db.stores, {
|
||||
experimental: {
|
||||
flags: {
|
||||
impactMetrics: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.destroy();
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('should store impact metrics in memory and be able to retrieve them', async () => {
|
||||
await sendImpactMetrics([
|
||||
{
|
||||
name: 'labeled_counter',
|
||||
help: 'with labels',
|
||||
type: 'counter',
|
||||
samples: [
|
||||
{
|
||||
labels: { foo: 'bar' },
|
||||
value: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await sendImpactMetrics([
|
||||
{
|
||||
name: 'labeled_counter',
|
||||
help: 'with labels',
|
||||
type: 'counter',
|
||||
samples: [
|
||||
{
|
||||
labels: { foo: 'bar' },
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await sendImpactMetrics([]);
|
||||
// missing help
|
||||
await sendImpactMetrics(
|
||||
[
|
||||
// @ts-expect-error
|
||||
{
|
||||
name: 'labeled_counter',
|
||||
type: 'counter',
|
||||
samples: [
|
||||
{
|
||||
labels: { foo: 'bar' },
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
400,
|
||||
);
|
||||
|
||||
const response = await app.request
|
||||
.get('/internal-backstage/impact/metrics')
|
||||
.expect('Content-Type', /text/)
|
||||
.expect(200);
|
||||
|
||||
const metricsText = response.text;
|
||||
|
||||
expect(metricsText).toContain('# HELP labeled_counter with labels');
|
||||
expect(metricsText).toContain('# TYPE labeled_counter counter');
|
||||
expect(metricsText).toMatch(/labeled_counter{foo="bar"} 15/);
|
||||
});
|
@ -1,11 +1,11 @@
|
||||
import { Counter, Gauge, type Registry } from 'prom-client';
|
||||
|
||||
interface MetricSample {
|
||||
export interface MetricSample {
|
||||
labels?: Record<string, string | number>;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
export interface Metric {
|
||||
name: string;
|
||||
help: string;
|
||||
type: 'counter' | 'gauge';
|
||||
|
@ -101,21 +101,12 @@ export default class ClientInstanceService {
|
||||
): Promise<void> {
|
||||
const value = await clientMetricsSchema.validateAsync(data);
|
||||
|
||||
if (this.flagResolver.isEnabled('lastSeenBulkQuery')) {
|
||||
this.seenClients[this.clientKey(value)] = {
|
||||
appName: value.appName,
|
||||
instanceId: value.instanceId,
|
||||
environment: value.environment,
|
||||
clientIp: clientIp,
|
||||
};
|
||||
} else {
|
||||
await this.clientInstanceStore.setLastSeen({
|
||||
appName: value.appName,
|
||||
instanceId: value.instanceId,
|
||||
environment: value.environment,
|
||||
clientIp: clientIp,
|
||||
});
|
||||
}
|
||||
this.seenClients[this.clientKey(value)] = {
|
||||
appName: value.appName,
|
||||
instanceId: value.instanceId,
|
||||
environment: value.environment,
|
||||
clientIp: clientIp,
|
||||
};
|
||||
}
|
||||
|
||||
public registerFrontendClient(data: IFrontendClientApp): void {
|
||||
|
@ -27,6 +27,7 @@ import { CLIENT_METRICS } from '../../../events/index.js';
|
||||
import type { CustomMetricsSchema } from '../../../openapi/spec/custom-metrics-schema.js';
|
||||
import type { StoredCustomMetric } from '../custom/custom-metrics-store.js';
|
||||
import type { CustomMetricsService } from '../custom/custom-metrics-service.js';
|
||||
import type { MetricsTranslator } from '../impact/metrics-translator.js';
|
||||
|
||||
export default class ClientMetricsController extends Controller {
|
||||
logger: Logger;
|
||||
@ -39,6 +40,8 @@ export default class ClientMetricsController extends Controller {
|
||||
|
||||
customMetricsService: CustomMetricsService;
|
||||
|
||||
metricsTranslator: MetricsTranslator;
|
||||
|
||||
flagResolver: IFlagResolver;
|
||||
|
||||
constructor(
|
||||
@ -150,16 +153,25 @@ export default class ClientMetricsController extends Controller {
|
||||
} else {
|
||||
try {
|
||||
const { body: data, ip: clientIp, user } = req;
|
||||
data.environment = this.metricsV2.resolveMetricsEnvironment(
|
||||
user,
|
||||
data,
|
||||
);
|
||||
const { impactMetrics, ...metricsData } = data;
|
||||
metricsData.environment =
|
||||
this.metricsV2.resolveMetricsEnvironment(user, metricsData);
|
||||
await this.clientInstanceService.registerInstance(
|
||||
data,
|
||||
metricsData,
|
||||
clientIp,
|
||||
);
|
||||
|
||||
await this.metricsV2.registerClientMetrics(data, clientIp);
|
||||
await this.metricsV2.registerClientMetrics(
|
||||
metricsData,
|
||||
clientIp,
|
||||
);
|
||||
if (
|
||||
this.flagResolver.isEnabled('impactMetrics') &&
|
||||
impactMetrics
|
||||
) {
|
||||
await this.metricsV2.registerImpactMetrics(impactMetrics);
|
||||
}
|
||||
|
||||
res.getHeaderNames().forEach((header) =>
|
||||
res.removeHeader(header),
|
||||
);
|
||||
|
@ -85,6 +85,35 @@ export const customMetricsSchema = joi
|
||||
metrics: joi.array().items(customMetricSchema).required(),
|
||||
});
|
||||
|
||||
export const metricSampleSchema = joi
|
||||
.object()
|
||||
.options({ stripUnknown: true })
|
||||
.keys({
|
||||
value: joi.number().required(),
|
||||
labels: joi
|
||||
.object()
|
||||
.pattern(
|
||||
joi.string(),
|
||||
joi.alternatives().try(joi.string(), joi.number()),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const impactMetricSchema = joi
|
||||
.object()
|
||||
.options({ stripUnknown: true })
|
||||
.keys({
|
||||
name: joi.string().required(),
|
||||
help: joi.string().required(),
|
||||
type: joi.string().required(),
|
||||
samples: joi.array().items(metricSampleSchema).required(),
|
||||
});
|
||||
|
||||
export const impactMetricsSchema = joi
|
||||
.array()
|
||||
.items(impactMetricSchema)
|
||||
.empty();
|
||||
|
||||
export const batchMetricsSchema = joi
|
||||
.object()
|
||||
.options({ stripUnknown: true })
|
||||
|
@ -11,6 +11,17 @@ export const eventSearchQueryParameters = [
|
||||
'Find events by a free-text search query. The query will be matched against the event data payload (if any).',
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
schema: {
|
||||
type: 'string',
|
||||
example: 'IS:123',
|
||||
pattern: '^(IS|IS_ANY_OF):(.*?)(,([0-9]+))*$',
|
||||
},
|
||||
description:
|
||||
'Filter by event ID using supported operators: IS, IS_ANY_OF.',
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
name: 'feature',
|
||||
schema: {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import stoppable, { type StoppableServer } from 'stoppable';
|
||||
import { promisify } from 'util';
|
||||
import version from './util/version.js';
|
||||
import { migrateDb, resetDb } from '../migrator.js';
|
||||
import { migrateDb, requiresMigration, resetDb } from '../migrator.js';
|
||||
import getApp from './app.js';
|
||||
import type MetricsMonitor from './metrics.js';
|
||||
import { createMetricsMonitor } from './metrics.js';
|
||||
@ -336,21 +336,25 @@ async function start(
|
||||
if (config.db.disableMigration) {
|
||||
logger.info('DB migration: disabled');
|
||||
} else {
|
||||
logger.info('DB migration: start');
|
||||
if (config.flagResolver.isEnabled('migrationLock')) {
|
||||
logger.info('Running migration with lock');
|
||||
const lock = withDbLock(config.db, {
|
||||
lockKey: defaultLockKey,
|
||||
timeout: defaultTimeout,
|
||||
logger,
|
||||
});
|
||||
await lock(migrateDb)(config);
|
||||
} else {
|
||||
logger.info('Running migration without lock');
|
||||
await migrateDb(config);
|
||||
}
|
||||
if (await requiresMigration(config)) {
|
||||
logger.info('DB migration: start');
|
||||
if (config.flagResolver.isEnabled('migrationLock')) {
|
||||
logger.info('Running migration with lock');
|
||||
const lock = withDbLock(config.db, {
|
||||
lockKey: defaultLockKey,
|
||||
timeout: defaultTimeout,
|
||||
logger,
|
||||
});
|
||||
await lock(migrateDb)(config);
|
||||
} else {
|
||||
logger.info('Running migration without lock');
|
||||
await migrateDb(config);
|
||||
}
|
||||
|
||||
logger.info('DB migration: end');
|
||||
logger.info('DB migration: end');
|
||||
} else {
|
||||
logger.info('DB migration: no migration needed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to migrate db', err);
|
||||
|
@ -52,7 +52,8 @@ const SCHEDULED_CHANGE_CONFLICT_SUBJECT =
|
||||
'Unleash - Scheduled changes can no longer be applied';
|
||||
const SCHEDULED_EXECUTION_FAILED_SUBJECT =
|
||||
'Unleash - Scheduled change request could not be applied';
|
||||
|
||||
const REQUESTED_CR_APPROVAL_SUBJECT =
|
||||
'Unleash - new change request waiting to be reviewed';
|
||||
export const MAIL_ACCEPTED = '250 Accepted';
|
||||
|
||||
export type ChangeRequestScheduleConflictData =
|
||||
@ -121,6 +122,67 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequestedCRApprovalEmail(
|
||||
recipient: string,
|
||||
changeRequestLink: string,
|
||||
changeRequestTitle: string,
|
||||
): Promise<IEmailEnvelope> {
|
||||
if (this.configured()) {
|
||||
const year = new Date().getFullYear();
|
||||
const bodyHtml = await this.compileTemplate(
|
||||
'requested-cr-approval',
|
||||
TemplateFormat.HTML,
|
||||
{
|
||||
changeRequestLink,
|
||||
changeRequestTitle,
|
||||
year,
|
||||
},
|
||||
);
|
||||
const bodyText = await this.compileTemplate(
|
||||
'requested-cr-approval',
|
||||
TemplateFormat.PLAIN,
|
||||
{
|
||||
changeRequestLink,
|
||||
changeRequestTitle,
|
||||
year,
|
||||
},
|
||||
);
|
||||
const email = {
|
||||
from: this.sender,
|
||||
to: recipient,
|
||||
subject: REQUESTED_CR_APPROVAL_SUBJECT,
|
||||
html: bodyHtml,
|
||||
text: bodyText,
|
||||
};
|
||||
process.nextTick(() => {
|
||||
this.mailer!.sendMail(email).then(
|
||||
() =>
|
||||
this.logger.info(
|
||||
'Successfully sent requested-cr-approval email',
|
||||
),
|
||||
(e) =>
|
||||
this.logger.warn(
|
||||
'Failed to send requested-cr-approval email',
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
return Promise.resolve(email);
|
||||
}
|
||||
return new Promise((res) => {
|
||||
this.logger.warn(
|
||||
'No mailer is configured. Please read the docs on how to configure an email service',
|
||||
);
|
||||
this.logger.debug('Change request link: ', changeRequestLink);
|
||||
res({
|
||||
from: this.sender,
|
||||
to: recipient,
|
||||
subject: REQUESTED_CR_APPROVAL_SUBJECT,
|
||||
html: '',
|
||||
text: '',
|
||||
});
|
||||
});
|
||||
}
|
||||
async sendScheduledExecutionFailedEmail(
|
||||
recipient: string,
|
||||
changeRequestLink: string,
|
||||
|
@ -17,7 +17,6 @@ export type IFlagKey =
|
||||
| 'migrationLock'
|
||||
| 'demo'
|
||||
| 'googleAuthEnabled'
|
||||
| 'disableBulkToggle'
|
||||
| 'advancedPlayground'
|
||||
| 'filterInvalidClientMetrics'
|
||||
| 'disableMetrics'
|
||||
@ -57,7 +56,6 @@ export type IFlagKey =
|
||||
| 'edgeObservability'
|
||||
| 'registerFrontendClient'
|
||||
| 'reportUnknownFlags'
|
||||
| 'lastSeenBulkQuery'
|
||||
| 'lifecycleMetrics'
|
||||
| 'customMetrics'
|
||||
| 'impactMetrics'
|
||||
@ -105,10 +103,6 @@ const flags: IFlags = {
|
||||
process.env.GOOGLE_AUTH_ENABLED,
|
||||
false,
|
||||
),
|
||||
disableBulkToggle: parseEnvVarBoolean(
|
||||
process.env.DISABLE_BULK_TOGGLE,
|
||||
false,
|
||||
),
|
||||
filterInvalidClientMetrics: parseEnvVarBoolean(
|
||||
process.env.FILTER_INVALID_CLIENT_METRICS,
|
||||
false,
|
||||
@ -272,10 +266,6 @@ const flags: IFlags = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
|
||||
false,
|
||||
),
|
||||
lastSeenBulkQuery: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_LAST_SEEN_BULK_QUERY,
|
||||
false,
|
||||
),
|
||||
lifecycleMetrics: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_LIFECYCLE_METRICS,
|
||||
false,
|
||||
@ -319,10 +309,19 @@ export interface IFlagResolver {
|
||||
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
|
||||
getVariant: (expName: IFlagKey, context?: IFlagContext) => Variant;
|
||||
getStaticContext: () => IFlagContext;
|
||||
impactMetrics?: IImpactMetricsResolver;
|
||||
}
|
||||
|
||||
export interface IExternalFlagResolver {
|
||||
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
|
||||
getVariant: (flagName: IFlagKey, context?: IFlagContext) => Variant;
|
||||
getStaticContext: () => IFlagContext;
|
||||
impactMetrics?: IImpactMetricsResolver;
|
||||
}
|
||||
|
||||
export interface IImpactMetricsResolver {
|
||||
defineCounter(name: string, help: string);
|
||||
defineGauge(name: string, help: string);
|
||||
incrementCounter(name: string, value?: number, featureName?: string): void;
|
||||
updateGauge(name: string, value: number, featureName?: string): void;
|
||||
}
|
||||
|
@ -164,6 +164,7 @@ export interface IUnleashOptions {
|
||||
clientFeatureCaching?: Partial<IClientCachingOption>;
|
||||
accessControlMaxAge?: number;
|
||||
prometheusApi?: string;
|
||||
prometheusImpactMetricsApi?: string;
|
||||
publicFolder?: string;
|
||||
disableScheduler?: boolean;
|
||||
metricsRateLimiting?: Partial<IMetricsRateLimiting>;
|
||||
@ -288,6 +289,7 @@ export interface IUnleashConfig {
|
||||
clientFeatureCaching: IClientCachingOption;
|
||||
accessControlMaxAge: number;
|
||||
prometheusApi?: string;
|
||||
prometheusImpactMetricsApi?: string;
|
||||
publicFolder?: string;
|
||||
disableScheduler?: boolean;
|
||||
isEnterprise: boolean;
|
||||
|
@ -18,11 +18,6 @@ export interface IClientInstanceStore
|
||||
Pick<INewClientInstance, 'appName' | 'instanceId'>
|
||||
> {
|
||||
bulkUpsert(instances: INewClientInstance[]): Promise<void>;
|
||||
/**
|
||||
* @deprecated
|
||||
* `bulkUpsert` is beeing used instead. remove with `lastSeenBulkQuery` flag
|
||||
*/
|
||||
setLastSeen(INewClientInstance): Promise<void>;
|
||||
insert(details: INewClientInstance): Promise<void>;
|
||||
getByAppName(appName: string): Promise<IClientInstance[]>;
|
||||
getRecentByAppNameAndEnvironment(
|
||||
|
@ -6,6 +6,7 @@ import type { IQueryOperations } from '../../features/events/event-store.js';
|
||||
import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type.js';
|
||||
|
||||
export interface IEventSearchParams {
|
||||
id?: string;
|
||||
project?: string;
|
||||
query?: string;
|
||||
feature?: string;
|
||||
|
@ -0,0 +1,379 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>*|MC:SUBJECT|*</title>
|
||||
<style type="text/css">
|
||||
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
|
||||
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
|
||||
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
|
||||
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
|
||||
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
|
||||
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
|
||||
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
|
||||
|
||||
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
|
||||
body{margin:0; padding:0;}
|
||||
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
|
||||
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
|
||||
|
||||
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
|
||||
|
||||
/* ========== Page Styles ========== */
|
||||
|
||||
#bodyCell{padding:20px;}
|
||||
#templateContainer{width:600px;}
|
||||
|
||||
/**
|
||||
* @tab Page
|
||||
* @section background style
|
||||
* @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding.
|
||||
* @theme page
|
||||
*/
|
||||
body, #bodyTable{
|
||||
background-color:#F7F7FA;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @tab Page
|
||||
* @section email border
|
||||
* @tip Set the border for your email.
|
||||
*/
|
||||
#templateContainer{
|
||||
border:2px solid #817AFE;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Page
|
||||
* @section heading 1
|
||||
* @tip Set the styling for all first-level headings in your emails. These should be the largest of your headings.
|
||||
* @style heading 1
|
||||
*/
|
||||
h1{
|
||||
color:#202021 !important;
|
||||
display:block;
|
||||
font-family:Helvetica;
|
||||
font-size:24px;
|
||||
font-style:normal;
|
||||
font-weight:bold;
|
||||
line-height:1.4;
|
||||
letter-spacing:normal;
|
||||
margin-top:0;
|
||||
margin-right:0;
|
||||
margin-bottom:32px;
|
||||
margin-left:0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Page
|
||||
* @section heading 2
|
||||
* @tip Set the styling for all second-level headings in your emails.
|
||||
* @style heading 2
|
||||
*/
|
||||
h2{
|
||||
color:#202021 !important;
|
||||
display:block;
|
||||
font-family:Helvetica;
|
||||
font-size:20px;
|
||||
font-style:normal;
|
||||
font-weight:bold;
|
||||
line-height:1.4;
|
||||
letter-spacing:normal;
|
||||
margin-top:0;
|
||||
margin-right:0;
|
||||
margin-bottom:24px;
|
||||
margin-left:0;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Page
|
||||
* @section heading 3
|
||||
* @tip Set the styling for all third-level headings in your emails.
|
||||
* @style heading 3
|
||||
*/
|
||||
h3{
|
||||
color:#202021 !important;
|
||||
display:block;
|
||||
font-family:Helvetica;
|
||||
font-size:16px;
|
||||
font-style:normal;
|
||||
font-weight:bold;
|
||||
line-height:1.4;
|
||||
letter-spacing:normal;
|
||||
margin-top:0;
|
||||
margin-right:0;
|
||||
margin-bottom:16px;
|
||||
margin-left:0;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Page
|
||||
* @section heading 4
|
||||
* @tip Set the styling for all fourth-level headings in your emails. These should be the smallest of your headings.
|
||||
* @style heading 4
|
||||
*/
|
||||
h4{
|
||||
color:#202021 !important;
|
||||
display:block;
|
||||
font-family:Helvetica;
|
||||
font-size:14px;
|
||||
font-style:normal;
|
||||
font-weight:normal;
|
||||
line-height:1.4;
|
||||
letter-spacing:normal;
|
||||
margin-top:0;
|
||||
margin-right:0;
|
||||
margin-bottom:16px;
|
||||
margin-left:0;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
/* ========== Header Styles ========== */
|
||||
|
||||
/**
|
||||
* @tab Header
|
||||
* @section header style
|
||||
* @tip Set the background color and borders for your email's header area.
|
||||
* @theme header
|
||||
*/
|
||||
#templateHeader{
|
||||
color:#6E6E70;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Header
|
||||
* @section header text
|
||||
* @tip Set the styling for your email's header text. Choose a size and color that is easy to read.
|
||||
*/
|
||||
.headerContent{
|
||||
color:#6E6E70;
|
||||
font-family:Helvetica;
|
||||
font-size:20px;
|
||||
font-weight:bold;
|
||||
line-height:1.4;
|
||||
padding-top:0;
|
||||
padding-right:0;
|
||||
padding-bottom:0;
|
||||
padding-left:0;
|
||||
text-align:left;
|
||||
vertical-align:middle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Header
|
||||
* @section header link
|
||||
* @tip Set the styling for your email's header links. Choose a color that helps them stand out from your text.
|
||||
*/
|
||||
.headerContent a:link, .headerContent a:visited, /* Yahoo! Mail Override */ .headerContent a .yshortcuts /* Yahoo! Mail Override */{
|
||||
color:#fff;
|
||||
font-weight:normal;
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
|
||||
/* ========== Body Styles ========== */
|
||||
|
||||
/**
|
||||
* @tab Body
|
||||
* @section body style
|
||||
* @tip Set the background color and borders for your email's body area.
|
||||
*/
|
||||
#templateBody{
|
||||
margin: 48px 0;
|
||||
padding: 48px 0;
|
||||
border-top: 1px solid #E1E1E3;
|
||||
border-bottom: 1px solid #E1E1E3;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Body
|
||||
* @section body text
|
||||
* @tip Set the styling for your email's main content text. Choose a size and color that is easy to read.
|
||||
* @theme main
|
||||
*/
|
||||
.bodyContent{
|
||||
color:#202021;
|
||||
font-family:Helvetica;
|
||||
font-size:16px;
|
||||
line-height:1.4;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
.bodyContent img{
|
||||
display:inline;
|
||||
height:auto;
|
||||
max-width:560px;
|
||||
}
|
||||
|
||||
.bodyContent a {
|
||||
color: #615BC2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bodyContent a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bodyContent .buttonStyle {
|
||||
margin-top: 32px;
|
||||
display: inline-block;
|
||||
padding: 13px 20px;
|
||||
color: #fff;
|
||||
background: #6C65E5;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* ========== Footer Styles ========== */
|
||||
|
||||
/**
|
||||
* @tab Footer
|
||||
* @section footer text
|
||||
* @tip Set the styling for your email's footer text. Choose a size and color that is easy to read.
|
||||
* @theme footer
|
||||
*/
|
||||
.footerContent{
|
||||
color:#6E6E70;
|
||||
font-family:Helvetica;
|
||||
font-size:14px;
|
||||
line-height:1.4;
|
||||
text-align:left;
|
||||
}
|
||||
|
||||
.footerContent a {
|
||||
padding-right: 12px;
|
||||
margin-right: 12px;
|
||||
border-right: 1px solid #E1E1E3;
|
||||
color:#615BC2;
|
||||
}
|
||||
|
||||
.footerContent em {
|
||||
display: block;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/**
|
||||
* @tab Footer
|
||||
* @section footer link
|
||||
* @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text.
|
||||
*/
|
||||
.footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{
|
||||
color:#615BC2;
|
||||
font-weight:normal;
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
/* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */
|
||||
|
||||
@media only screen and (max-width: 480px){
|
||||
/* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */
|
||||
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */
|
||||
body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */
|
||||
|
||||
/* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */
|
||||
#bodyCell{padding:10px !important;}
|
||||
|
||||
/* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */
|
||||
|
||||
/* ======== Page Styles ======== */
|
||||
|
||||
/**
|
||||
* @tab Mobile Styles
|
||||
* @section template width
|
||||
* @tip Make the template fluid for portrait or landscape view adaptability. If a fluid layout doesn't work for you, set the width to 300px instead.
|
||||
*/
|
||||
#templateContainer{
|
||||
max-width:600px !important;
|
||||
width:100% !important;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
|
||||
/* ======== Body Styles ======== */
|
||||
|
||||
#templateBody{
|
||||
margin: 32px 0;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
/* ======== Footer Styles ======== */
|
||||
|
||||
.footerContent a {
|
||||
display:block !important;
|
||||
border-right: 0;
|
||||
padding: 6px 0;
|
||||
} /* Place footer social and utility links on their own lines, for easier access */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
|
||||
<center>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable" style="background:#F7F7FA;">
|
||||
<tr>
|
||||
<td align="center" valign="top" id="bodyCell">
|
||||
<!-- BEGIN TEMPLATE // -->
|
||||
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer" style="background:#FFFFFF;">
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<!-- BEGIN HEADER // -->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
|
||||
<tr>
|
||||
<td valign="top" class="headerContent">
|
||||
<img src="https://cdn.getunleash.io/unleash_logo_600.png" style="width:140px;" id="headerImage" mc:label="header_image" mc:edit="header_image" mc:allowdesigner mc:allowtext />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END HEADER -->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<!-- BEGIN BODY // -->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
|
||||
<tr>
|
||||
<td valign="top" class="bodyContent" mc:edit="body_content">
|
||||
<h1>You have been added to review {{{ changeRequestTitle }}}</h1>
|
||||
<p>Click <a class="changeRequestLink" href="{{{ changeRequestLink }}}" target="_blank" rel="noopener noreferrer">{{{changeRequestLink}}}</a> to review it</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END BODY -->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<!-- BEGIN FOOTER // -->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
|
||||
<tr>
|
||||
<td valign="top" class="footerContent" mc:edit="footer_content00">
|
||||
<a href="https://www.getunleash.io/blog">Unleash blog</a>
|
||||
<a href="https://github.com/Unleash/unleash">Github</a>
|
||||
<a href="https://slack.unleash.run">Slack community</a>
|
||||
<a href="https://www.getunleash.io/support" style="border-right:0;">Help center</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" class="footerContent" mc:edit="footer_content01">
|
||||
<em>Copyright © {{ year }} | Bricks Software | All rights reserved.</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END FOOTER -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- // END TEMPLATE -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,3 @@
|
||||
You have been added to review {{{ changeRequestTitle }}}
|
||||
|
||||
Follow the link: {{{ changeRequestLink }}} to review it.
|
@ -0,0 +1,19 @@
|
||||
exports.up = function(db, cb) {
|
||||
db.runSql(`CREATE TABLE change_request_requested_approvers(
|
||||
change_request_id INTEGER REFERENCES change_requests(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
requested_at TIMESTAMP WITH TIME ZONE DEFAULT (now() at time zone 'utc'),
|
||||
PRIMARY KEY (change_request_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS change_request_requested_approvers_cr_id_idx ON change_request_requested_approvers(change_request_id);
|
||||
CREATE INDEX IF NOT EXISTS change_request_requested_approvers_user_id_idx ON change_request_requested_approvers(user_id);
|
||||
`, cb)
|
||||
};
|
||||
|
||||
exports.down = function(db, cb) {
|
||||
db.runSql(`DROP TABLE IF EXISTS change_request_requested_approvers`, cb);
|
||||
};
|
||||
|
||||
exports._meta = {
|
||||
"version": 1
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
exports.up = function(db, cb) {
|
||||
db.runSql(`ALTER TABLE change_request_requested_approvers ADD COLUMN notified_at TIMESTAMP WITH TIME ZONE;
|
||||
CREATE INDEX IF NOT EXISTS cr_req_approvers_notified_at_idx ON change_request_requested_approvers(notified_at);`, cb);
|
||||
};
|
||||
|
||||
exports.down = function(db, cb) {
|
||||
db.runSql(`
|
||||
DROP INDEX IF EXISTS cr_req_approvers_notified_at_idx;
|
||||
ALTER TABLE change_request_requested_approvers DROP COLUMN notified_at;`, cb);
|
||||
};
|
@ -40,6 +40,28 @@ export async function migrateDb(
|
||||
});
|
||||
}
|
||||
|
||||
export async function requiresMigration({
|
||||
db,
|
||||
}: Pick<IUnleashConfig, 'db'>): Promise<boolean> {
|
||||
return noDatabaseUrl(async () => {
|
||||
const custom = {
|
||||
...db,
|
||||
connectionTimeoutMillis: secondsToMilliseconds(10),
|
||||
};
|
||||
|
||||
// disable Intellij/WebStorm from setting verbose CLI argument to db-migrator
|
||||
process.argv = process.argv.filter((it) => !it.includes('--verbose'));
|
||||
const dbm = getInstance(true, {
|
||||
cwd: __dirname,
|
||||
config: { custom },
|
||||
env: 'custom',
|
||||
});
|
||||
|
||||
const pendingMigrations = await dbm.check();
|
||||
return pendingMigrations.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// This exists to ease testing
|
||||
export async function resetDb({ db }: IUnleashConfig): Promise<void> {
|
||||
return noDatabaseUrl(async () => {
|
||||
|
@ -56,6 +56,7 @@ process.nextTick(async () => {
|
||||
customMetrics: true,
|
||||
lifecycleMetrics: true,
|
||||
improvedJsonDiff: true,
|
||||
impactMetrics: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
@ -69,6 +70,7 @@ process.nextTick(async () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
prometheusImpactMetricsApi: 'http://localhost:9090',
|
||||
/* can be tweaked to control configuration caching for /api/client/features
|
||||
clientFeatureCaching: {
|
||||
enabled: true,
|
||||
|
@ -618,3 +618,103 @@ test('should filter events by environment using IS_ANY_OF', async () => {
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('should filter events by ID', async () => {
|
||||
await eventService.storeEvent({
|
||||
type: FEATURE_CREATED,
|
||||
project: 'default',
|
||||
data: { name: 'feature1' },
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
await eventService.storeEvent({
|
||||
type: FEATURE_CREATED,
|
||||
project: 'default',
|
||||
data: { name: 'feature2' },
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
await eventService.storeEvent({
|
||||
type: FEATURE_CREATED,
|
||||
project: 'default',
|
||||
data: { name: 'feature3' },
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
const { body: allEventsResponse } = await searchEvents({});
|
||||
const targetEvent = allEventsResponse.events.find(
|
||||
(e: any) => e.data.name === 'feature2',
|
||||
);
|
||||
|
||||
const { body } = await searchEvents({ id: `IS:${targetEvent.id}` });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
events: [
|
||||
{
|
||||
id: targetEvent.id,
|
||||
type: 'feature-created',
|
||||
data: { name: 'feature2' },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should filter events by multiple IDs using IS_ANY_OF', async () => {
|
||||
await eventService.storeEvent({
|
||||
type: FEATURE_CREATED,
|
||||
project: 'default',
|
||||
data: { name: 'feature1' },
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
await eventService.storeEvent({
|
||||
type: FEATURE_CREATED,
|
||||
project: 'default',
|
||||
data: { name: 'feature2' },
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
await eventService.storeEvent({
|
||||
type: FEATURE_CREATED,
|
||||
project: 'default',
|
||||
data: { name: 'feature3' },
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
|
||||
const { body: allEventsResponse } = await searchEvents({});
|
||||
const targetEvent1 = allEventsResponse.events.find(
|
||||
(e: any) => e.data.name === 'feature1',
|
||||
);
|
||||
const targetEvent3 = allEventsResponse.events.find(
|
||||
(e: any) => e.data.name === 'feature3',
|
||||
);
|
||||
|
||||
const { body } = await searchEvents({
|
||||
id: `IS_ANY_OF:${targetEvent1.id},${targetEvent3.id}`,
|
||||
});
|
||||
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.events).toHaveLength(2);
|
||||
|
||||
const returnedIds = body.events.map((e: any) => e.id);
|
||||
expect(returnedIds).toContain(targetEvent1.id);
|
||||
expect(returnedIds).toContain(targetEvent3.id);
|
||||
|
||||
const feature2Event = allEventsResponse.events.find(
|
||||
(e: any) => e.data.name === 'feature2',
|
||||
);
|
||||
expect(returnedIds).not.toContain(feature2Event.id);
|
||||
});
|
||||
|
@ -338,3 +338,137 @@ test('getMaxRevisionId should exclude FEATURE_CREATED and FEATURE_TAGGED events'
|
||||
expect(updatedEvent!.id).toBeGreaterThan(taggedEvent!.id);
|
||||
expect(segmentEvent!.id).toBeGreaterThan(updatedEvent!.id);
|
||||
});
|
||||
|
||||
test('Should filter events by ID using IS operator', async () => {
|
||||
const event1 = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature1' },
|
||||
};
|
||||
const event2 = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature2' },
|
||||
};
|
||||
const event3 = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature3' },
|
||||
};
|
||||
|
||||
await eventStore.store(event1);
|
||||
await eventStore.store(event2);
|
||||
await eventStore.store(event3);
|
||||
|
||||
const allEvents = await eventStore.getAll();
|
||||
const targetEvent = allEvents.find((e) => e.data.name === 'feature2');
|
||||
|
||||
const filteredEvents = await eventStore.searchEvents(
|
||||
{
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
[
|
||||
{
|
||||
field: 'id',
|
||||
operator: 'IS',
|
||||
values: [targetEvent!.id.toString()],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(1);
|
||||
expect(filteredEvents[0].id).toBe(targetEvent!.id);
|
||||
expect(filteredEvents[0].data.name).toBe('feature2');
|
||||
});
|
||||
|
||||
test('Should filter events by ID using IS_ANY_OF operator', async () => {
|
||||
const event1 = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature1' },
|
||||
};
|
||||
const event2 = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature2' },
|
||||
};
|
||||
const event3 = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature3' },
|
||||
};
|
||||
|
||||
await eventStore.store(event1);
|
||||
await eventStore.store(event2);
|
||||
await eventStore.store(event3);
|
||||
|
||||
const allEvents = await eventStore.getAll();
|
||||
const targetEvent1 = allEvents.find((e) => e.data.name === 'feature1');
|
||||
const targetEvent3 = allEvents.find((e) => e.data.name === 'feature3');
|
||||
|
||||
const filteredEvents = await eventStore.searchEvents(
|
||||
{
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
[
|
||||
{
|
||||
field: 'id',
|
||||
operator: 'IS_ANY_OF',
|
||||
values: [
|
||||
targetEvent1!.id.toString(),
|
||||
targetEvent3!.id.toString(),
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(2);
|
||||
const eventIds = filteredEvents.map((e) => e.id);
|
||||
expect(eventIds).toContain(targetEvent1!.id);
|
||||
expect(eventIds).toContain(targetEvent3!.id);
|
||||
expect(eventIds).not.toContain(
|
||||
allEvents.find((e) => e.data.name === 'feature2')!.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should return empty result when filtering by non-existent ID', async () => {
|
||||
const event = {
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'test-user',
|
||||
createdByUserId: TEST_USER_ID,
|
||||
ip: '127.0.0.1',
|
||||
data: { name: 'feature1' },
|
||||
};
|
||||
|
||||
await eventStore.store(event);
|
||||
|
||||
const filteredEvents = await eventStore.searchEvents(
|
||||
{
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
},
|
||||
[
|
||||
{
|
||||
field: 'id',
|
||||
operator: 'IS',
|
||||
values: ['999999'],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(filteredEvents).toHaveLength(0);
|
||||
});
|
||||
|
@ -28,10 +28,6 @@ export default class FakeClientInstanceStore implements IClientInstanceStore {
|
||||
);
|
||||
}
|
||||
|
||||
setLastSeen(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async getBySdkName(sdkName: string): Promise<IClientInstance[]> {
|
||||
return this.instances.filter((instance) =>
|
||||
instance.sdkVersion?.startsWith(sdkName),
|
||||
|
Loading…
Reference in New Issue
Block a user