mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-02 01:17:58 +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
|
run: echo "PR_CREATOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Post a notification about core feature changes if not already commented
|
- name: Post a notification about core feature changes if not already commented
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prCreator = process.env.PR_CREATOR;
|
const prCreator = process.env.PR_CREATOR;
|
||||||
@ -45,7 +45,7 @@ jobs:
|
|||||||
console.log('Comment already exists, skipping.');
|
console.log('Comment already exists, skipping.');
|
||||||
}
|
}
|
||||||
- name: Add reviewers to the PR if they are not the creator
|
- name: Add reviewers to the PR if they are not the creator
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prCreator = process.env.PR_CREATOR;
|
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.
|
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
|
## [7.0.1] - 2025-06-13
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
@ -64,10 +64,10 @@
|
|||||||
"@types/react-router-dom": "5.3.3",
|
"@types/react-router-dom": "5.3.3",
|
||||||
"@types/react-table": "7.7.20",
|
"@types/react-table": "7.7.20",
|
||||||
"@types/react-test-renderer": "18.3.1",
|
"@types/react-test-renderer": "18.3.1",
|
||||||
"@types/semver": "7.5.8",
|
"@types/semver": "7.7.0",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.0",
|
||||||
"@uiw/codemirror-theme-duotone": "4.23.10",
|
"@uiw/codemirror-theme-duotone": "4.23.13",
|
||||||
"@uiw/react-codemirror": "4.23.10",
|
"@uiw/react-codemirror": "4.23.13",
|
||||||
"@unleash/proxy-client-react": "^5.0.0",
|
"@unleash/proxy-client-react": "^5.0.0",
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
"cartesian": "^1.0.1",
|
"cartesian": "^1.0.1",
|
||||||
@ -114,7 +114,7 @@
|
|||||||
"react-table": "7.8.0",
|
"react-table": "7.8.0",
|
||||||
"react-test-renderer": "18.3.1",
|
"react-test-renderer": "18.3.1",
|
||||||
"sass": "1.85.1",
|
"sass": "1.85.1",
|
||||||
"semver": "7.7.1",
|
"semver": "7.7.2",
|
||||||
"swr": "2.3.3",
|
"swr": "2.3.3",
|
||||||
"tss-react": "4.9.15",
|
"tss-react": "4.9.15",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
@ -134,7 +134,7 @@
|
|||||||
"jsonpath-plus": "10.3.0",
|
"jsonpath-plus": "10.3.0",
|
||||||
"json5": "^2.2.2",
|
"json5": "^2.2.2",
|
||||||
"vite": "5.4.19",
|
"vite": "5.4.19",
|
||||||
"semver": "7.7.1",
|
"semver": "7.7.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"@types/react": "18.3.18"
|
"@types/react": "18.3.18"
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useImperativeHandle } from 'react';
|
import { useEffect, useImperativeHandle } from 'react';
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { IConstraint } from 'interfaces/strategy';
|
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 { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint';
|
||||||
import { createEmptyConstraint } from '../../../../utils/createEmptyConstraint.ts';
|
import { createEmptyConstraint } from '../../../../utils/createEmptyConstraint.ts';
|
||||||
import { constraintId } from 'constants/constraintId.ts';
|
import { constraintId } from 'constants/constraintId.ts';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
export interface IEditableConstraintsListRef {
|
export interface IEditableConstraintsListRef {
|
||||||
addConstraint?: (contextName: string) => void;
|
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) => {
|
const onDelete = (index: number) => {
|
||||||
setConstraints(
|
setConstraints(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
@ -23,6 +23,7 @@ interface IEventDiffProps {
|
|||||||
* @deprecated remove with flag improvedJsonDiff
|
* @deprecated remove with flag improvedJsonDiff
|
||||||
*/
|
*/
|
||||||
sort?: (a: IEventDiffResult, b: IEventDiffResult) => number;
|
sort?: (a: IEventDiffResult, b: IEventDiffResult) => number;
|
||||||
|
excludeKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiffStyles = styled('div')(({ theme }) => ({
|
const DiffStyles = styled('div')(({ theme }) => ({
|
||||||
@ -37,7 +38,6 @@ const DiffStyles = styled('div')(({ theme }) => ({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
marginLeft: '-10px',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -47,35 +47,65 @@ const DiffStyles = styled('div')(({ theme }) => ({
|
|||||||
content: '"+"',
|
content: '"+"',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
'.deletion': {
|
'.deletion': {
|
||||||
color: theme.palette.eventLog.diffSub,
|
color: theme.palette.eventLog.diffSub,
|
||||||
'::before': {
|
'::before': {
|
||||||
content: '"-"',
|
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 [full, setFull] = useState(false);
|
||||||
const diffId = useId();
|
const diffId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{showExpandButton ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setFull(!full)}
|
onClick={() => setFull(!full)}
|
||||||
aria-controls={diffId}
|
aria-controls={diffId}
|
||||||
aria-expanded={full}
|
aria-expanded={full}
|
||||||
>
|
>
|
||||||
{full ? 'Show only changed properties' : 'Show all properties'}
|
<ButtonIcon aria-hidden>{full ? '-' : '+'}</ButtonIcon>
|
||||||
|
{full
|
||||||
|
? 'Show only changed properties'
|
||||||
|
: 'Show all properties'}
|
||||||
</Button>
|
</Button>
|
||||||
<DiffStyles id={diffId}>
|
) : null}
|
||||||
|
<DiffStyles data-change-type={changeType} id={diffId}>
|
||||||
<JsonDiffComponent
|
<JsonDiffComponent
|
||||||
jsonA={(entry.preData ?? {}) as JsonValue}
|
jsonA={(entry.preData ?? {}) as JsonValue}
|
||||||
jsonB={(entry.data ?? {}) as JsonValue}
|
jsonB={(entry.data ?? {}) as JsonValue}
|
||||||
jsonDiffOptions={{
|
jsonDiffOptions={{
|
||||||
full: full,
|
full: full,
|
||||||
maxElisions: 2,
|
maxElisions: 2,
|
||||||
excludeKeys: ['id', 'createdAt', 'updatedAt'],
|
excludeKeys: excludeKeys,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DiffStyles>
|
</DiffStyles>
|
||||||
|
@ -72,6 +72,7 @@ export const useEventLogSearch = (
|
|||||||
createdBy: FilterItemParam,
|
createdBy: FilterItemParam,
|
||||||
type: FilterItemParam,
|
type: FilterItemParam,
|
||||||
environment: FilterItemParam,
|
environment: FilterItemParam,
|
||||||
|
id: StringParam,
|
||||||
...extraParameters(logType),
|
...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 {
|
import {
|
||||||
type DateOperator,
|
type DateOperator,
|
||||||
isDateOperator,
|
isDateOperator,
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
isSemVerOperator,
|
isSemVerOperator,
|
||||||
} from 'constants/operators';
|
} from 'constants/operators';
|
||||||
import type { IConstraint } from 'interfaces/strategy';
|
import type { IConstraint } from 'interfaces/strategy';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
type EditableConstraintBase = Omit<
|
type EditableConstraintBase = Omit<
|
||||||
IConstraint,
|
IConstraint,
|
||||||
@ -72,12 +74,14 @@ export const fromIConstraint = (
|
|||||||
const { value, values, operator, ...rest } = constraint;
|
const { value, values, operator, ...rest } = constraint;
|
||||||
if (isSingleValueOperator(operator)) {
|
if (isSingleValueOperator(operator)) {
|
||||||
return {
|
return {
|
||||||
|
[constraintId]: uuidv4(),
|
||||||
...rest,
|
...rest,
|
||||||
operator,
|
operator,
|
||||||
value: value ?? '',
|
value: value ?? '',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
[constraintId]: uuidv4(),
|
||||||
...rest,
|
...rest,
|
||||||
operator,
|
operator,
|
||||||
values: new Set(values),
|
values: new Set(values),
|
||||||
@ -92,7 +96,7 @@ export const toIConstraint = (constraint: EditableConstraint): IConstraint => {
|
|||||||
const { values, ...rest } = constraint;
|
const { values, ...rest } = constraint;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...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.
|
// gets called by useEffect, so we need to wait for the next render.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onUpdate).toHaveBeenCalledWith({
|
expect(onUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
contextName: 'context-field',
|
contextName: 'context-field',
|
||||||
operator: IN,
|
operator: IN,
|
||||||
values: [],
|
values: [],
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/Pro
|
|||||||
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm.tsx';
|
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm.tsx';
|
||||||
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||||
import { Limit } from 'component/common/Limit/Limit';
|
import { Limit } from 'component/common/Limit/Limit';
|
||||||
|
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||||
|
|
||||||
const useStrategyLimit = (strategyCount: number) => {
|
const useStrategyLimit = (strategyCount: number) => {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
@ -280,7 +281,7 @@ export const formatAddStrategyApiCode = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
|
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}' \\
|
return `curl --location --request POST '${url}' \\
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
@ -35,6 +35,7 @@ import {
|
|||||||
getChangeRequestConflictCreatedDataFromScheduleData,
|
getChangeRequestConflictCreatedDataFromScheduleData,
|
||||||
} from './change-request-conflict-data.ts';
|
} from './change-request-conflict-data.ts';
|
||||||
import { constraintId } from 'constants/constraintId.ts';
|
import { constraintId } from 'constants/constraintId.ts';
|
||||||
|
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||||
|
|
||||||
const useTitleTracking = () => {
|
const useTitleTracking = () => {
|
||||||
const [previousTitle, setPreviousTitle] = useState<string>('');
|
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 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}' \\
|
return `curl --location --request PUT '${url}' \\
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
--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 }) => ({
|
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||||
width: '100px',
|
width: '100px',
|
||||||
}));
|
}));
|
||||||
@ -173,11 +151,11 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
|
|||||||
marginLeft: theme.spacing(1),
|
marginLeft: theme.spacing(1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledConstraintSeparator = styled(ConstraintSeparator)(({ theme }) => ({
|
const StyledConstraintSeparator = styled(ConstraintSeparator)({
|
||||||
top: '-10px',
|
top: '-10px',
|
||||||
left: '0',
|
left: '0',
|
||||||
transform: 'translateY(0)',
|
transform: 'translateY(0)',
|
||||||
}));
|
});
|
||||||
|
|
||||||
export const FeatureStrategyForm = ({
|
export const FeatureStrategyForm = ({
|
||||||
projectId,
|
projectId,
|
||||||
|
@ -59,6 +59,7 @@ export const StyledProjectTitle = styled('h1')(({ theme }) => ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
lineHeight: 1.5,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const StyledSeparator = styled('div')(({ theme }) => ({
|
export const StyledSeparator = styled('div')(({ theme }) => ({
|
||||||
|
@ -9,7 +9,6 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
|||||||
import { BulkDisableDialog } from 'component/feature/FeatureToggleList/BulkDisableDialog';
|
import { BulkDisableDialog } from 'component/feature/FeatureToggleList/BulkDisableDialog';
|
||||||
import { BulkEnableDialog } from 'component/feature/FeatureToggleList/BulkEnableDialog';
|
import { BulkEnableDialog } from 'component/feature/FeatureToggleList/BulkEnableDialog';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
|
|
||||||
interface IProjectFeaturesBatchActionsProps {
|
interface IProjectFeaturesBatchActionsProps {
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
@ -72,10 +71,6 @@ export const ProjectFeaturesBatchActions: FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
|
||||||
condition={Boolean(uiConfig?.flags?.disableBulkToggle)}
|
|
||||||
show={null}
|
|
||||||
elseShow={
|
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
size='small'
|
size='small'
|
||||||
@ -83,12 +78,6 @@ export const ProjectFeaturesBatchActions: FC<
|
|||||||
>
|
>
|
||||||
Enable
|
Enable
|
||||||
</Button>
|
</Button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={Boolean(uiConfig?.flags?.disableBulkToggle)}
|
|
||||||
show={null}
|
|
||||||
elseShow={
|
|
||||||
<Button
|
<Button
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
size='small'
|
size='small'
|
||||||
@ -96,8 +85,6 @@ export const ProjectFeaturesBatchActions: FC<
|
|||||||
>
|
>
|
||||||
Disable
|
Disable
|
||||||
</Button>
|
</Button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
featureIds={selectedIds}
|
featureIds={selectedIds}
|
||||||
|
@ -21,6 +21,7 @@ import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValues
|
|||||||
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
|
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
|
||||||
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
|
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
|
||||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
||||||
|
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
|
||||||
|
|
||||||
interface ICreateSegmentProps {
|
interface ICreateSegmentProps {
|
||||||
modal?: boolean;
|
modal?: boolean;
|
||||||
@ -61,7 +62,7 @@ export const CreateSegment = ({ modal }: ICreateSegmentProps) => {
|
|||||||
return `curl --location --request POST '${uiConfig.unleashUrl}/api/admin/segments' \\
|
return `curl --location --request POST '${uiConfig.unleashUrl}/api/admin/segments' \\
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
--header 'Content-Type: application/json' \\
|
--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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
@ -24,15 +24,32 @@ import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentL
|
|||||||
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
|
||||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||||
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
|
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 {
|
interface IEditSegmentProps {
|
||||||
modal?: boolean;
|
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) => {
|
export const EditSegment = ({ modal }: IEditSegmentProps) => {
|
||||||
const projectId = useOptionalPathParam('projectId');
|
const projectId = useOptionalPathParam('projectId');
|
||||||
const segmentId = useRequiredPathParam('segmentId');
|
const segmentId = useRequiredPathParam('segmentId');
|
||||||
const { segment } = useSegment(Number(segmentId));
|
const { segment: segmentWithoutConstraintIds } = useSegment(
|
||||||
|
Number(segmentId),
|
||||||
|
);
|
||||||
|
const segment = addIdSymbolToConstraints(segmentWithoutConstraintIds);
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const { setToastData, setToastApiError } = useToast();
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -72,7 +89,7 @@ export const EditSegment = ({ modal }: IEditSegmentProps) => {
|
|||||||
}/api/admin/segments/${segmentId}' \\
|
}/api/admin/segments/${segmentId}' \\
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
--header 'Content-Type: application/json' \\
|
--header 'Content-Type: application/json' \\
|
||||||
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
|
--data-raw '${JSON.stringify(getSegmentPayload(), apiPayloadConstraintReplacer, 2)}'`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const highestPermissionChangeRequestEnv =
|
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 { SegmentFormStepOne } from './SegmentFormStepOne.tsx';
|
||||||
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
|
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
@ -14,7 +14,7 @@ interface ISegmentProps {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
constraints: IConstraint[];
|
constraints: IConstraintWithId[];
|
||||||
setName: React.Dispatch<React.SetStateAction<string>>;
|
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setProject: React.Dispatch<React.SetStateAction<string | undefined>>;
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||||
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
|
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
|
||||||
import type { IConstraint } from 'interfaces/strategy';
|
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
CREATE_SEGMENT,
|
CREATE_SEGMENT,
|
||||||
UPDATE_PROJECT_SEGMENT,
|
UPDATE_PROJECT_SEGMENT,
|
||||||
} from 'component/providers/AccessProvider/permissions';
|
} from 'component/providers/AccessProvider/permissions';
|
||||||
|
import type { IConstraintWithId } from 'interfaces/strategy.ts';
|
||||||
|
|
||||||
const server = testServerSetup();
|
const server = testServerSetup();
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ const setupRoutes = () => {
|
|||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
project: undefined,
|
project: undefined,
|
||||||
constraints: [] as IConstraint[],
|
constraints: [] as IConstraintWithId[],
|
||||||
setConstraints: vi.fn(),
|
setConstraints: vi.fn(),
|
||||||
setCurrentStep: vi.fn(),
|
setCurrentStep: vi.fn(),
|
||||||
mode: 'create' as const,
|
mode: 'create' as const,
|
||||||
|
@ -13,7 +13,7 @@ import {
|
|||||||
UPDATE_SEGMENT,
|
UPDATE_SEGMENT,
|
||||||
} from 'component/providers/AccessProvider/permissions';
|
} from 'component/providers/AccessProvider/permissions';
|
||||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { EditableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
|
import { EditableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
|
||||||
import type { IEditableConstraintsListRef } 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 {
|
interface ISegmentFormPartTwoProps {
|
||||||
project?: string;
|
project?: string;
|
||||||
constraints: IConstraint[];
|
constraints: IConstraintWithId[];
|
||||||
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
|
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
|
||||||
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
|
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
|
||||||
mode: SegmentFormMode;
|
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 { useEffect, useState } from 'react';
|
||||||
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
|
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { constraintId } from 'constants/constraintId';
|
||||||
|
|
||||||
export const useSegmentForm = (
|
export const useSegmentForm = (
|
||||||
initialName = '',
|
initialName = '',
|
||||||
@ -11,8 +13,13 @@ export const useSegmentForm = (
|
|||||||
const [name, setName] = useState(initialName);
|
const [name, setName] = useState(initialName);
|
||||||
const [description, setDescription] = useState(initialDescription);
|
const [description, setDescription] = useState(initialDescription);
|
||||||
const [project, setProject] = useState<string | undefined>(initialProject);
|
const [project, setProject] = useState<string | undefined>(initialProject);
|
||||||
const [constraints, setConstraints] =
|
const initialConstraintsWithId = initialConstraints.map((constraint) => ({
|
||||||
useState<IConstraint[]>(initialConstraints);
|
[constraintId]: uuidv4(),
|
||||||
|
...constraint,
|
||||||
|
}));
|
||||||
|
const [constraints, setConstraints] = useState<IConstraintWithId[]>(
|
||||||
|
initialConstraintsWithId,
|
||||||
|
);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const nameError = useSegmentValidation(name, initialName);
|
const nameError = useSegmentValidation(name, initialName);
|
||||||
|
|
||||||
@ -29,7 +36,7 @@ export const useSegmentForm = (
|
|||||||
}, [initialProject]);
|
}, [initialProject]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConstraints(initialConstraints);
|
setConstraints(initialConstraintsWithId);
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [JSON.stringify(initialConstraints)]);
|
}, [JSON.stringify(initialConstraints)]);
|
||||||
|
|
||||||
@ -61,7 +68,9 @@ export const useSegmentForm = (
|
|||||||
project,
|
project,
|
||||||
setProject,
|
setProject,
|
||||||
constraints,
|
constraints,
|
||||||
setConstraints,
|
setConstraints: setConstraints as React.Dispatch<
|
||||||
|
React.SetStateAction<IConstraint[]>
|
||||||
|
>,
|
||||||
getSegmentPayload,
|
getSegmentPayload,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
errors,
|
errors,
|
||||||
|
@ -68,6 +68,10 @@ export interface IConstraint {
|
|||||||
[constraintId]?: string;
|
[constraintId]?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IConstraintWithId extends IConstraint {
|
||||||
|
[constraintId]: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IFeatureStrategySortOrder {
|
export interface IFeatureStrategySortOrder {
|
||||||
id: string;
|
id: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
@ -59,7 +59,6 @@ export type UiFlags = {
|
|||||||
personalAccessTokensKillSwitch?: boolean;
|
personalAccessTokensKillSwitch?: boolean;
|
||||||
demo?: boolean;
|
demo?: boolean;
|
||||||
googleAuthEnabled?: boolean;
|
googleAuthEnabled?: boolean;
|
||||||
disableBulkToggle?: boolean;
|
|
||||||
advancedPlayground?: boolean;
|
advancedPlayground?: boolean;
|
||||||
strategyVariant?: boolean;
|
strategyVariant?: boolean;
|
||||||
doraMetrics?: 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 { constraintId } from 'constants/constraintId';
|
||||||
import { dateOperators } from 'constants/operators';
|
import { isDateOperator } from 'constants/operators';
|
||||||
import type { IConstraint } from 'interfaces/strategy';
|
import type { IConstraintWithId } from 'interfaces/strategy';
|
||||||
import { oneOf } from 'utils/oneOf';
|
|
||||||
import { operatorsForContext } from 'utils/operatorsForContext';
|
import { operatorsForContext } from 'utils/operatorsForContext';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export const createEmptyConstraint = (contextName: string): IConstraint => {
|
export const createEmptyConstraint = (
|
||||||
|
contextName: string,
|
||||||
|
): IConstraintWithId => {
|
||||||
const operator = operatorsForContext(contextName)[0];
|
const operator = operatorsForContext(contextName)[0];
|
||||||
|
|
||||||
const value = oneOf(dateOperators, operator)
|
const value = isDateOperator(operator) ? new Date().toISOString() : '';
|
||||||
? new Date().toISOString()
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextName,
|
contextName,
|
||||||
|
@ -2877,14 +2877,14 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@tanstack/react-virtual@npm:^3.11.3":
|
"@tanstack/react-virtual@npm:^3.11.3":
|
||||||
version: 3.13.9
|
version: 3.13.10
|
||||||
resolution: "@tanstack/react-virtual@npm:3.13.9"
|
resolution: "@tanstack/react-virtual@npm:3.13.10"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/virtual-core": "npm:3.13.9"
|
"@tanstack/virtual-core": "npm:3.13.10"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
checksum: 10c0/aa05fb24e30686516e74ccdec94a83d195615a4f29bc866a53ee6b0107e466c6d6e3e947b3fb0613b907b0f982d74b00367868cf6b1ac956562d0e7c24d6764b
|
checksum: 10c0/587ef4db703cc9d870ee68b3f1471118fe69920e8f59cf627a359d518331c6684069fb3e159cd35ac458ea60a829b66953b774314833e1cb10b365255791559e
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -2895,10 +2895,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@tanstack/virtual-core@npm:3.13.9":
|
"@tanstack/virtual-core@npm:3.13.10":
|
||||||
version: 3.13.9
|
version: 3.13.10
|
||||||
resolution: "@tanstack/virtual-core@npm:3.13.9"
|
resolution: "@tanstack/virtual-core@npm:3.13.10"
|
||||||
checksum: 10c0/6e9526a9d52f8ddc54af8a1dc7380814b10ab38d8a4265e362a5b69c3132097ace51496d4206fe8aa90e33129aaf1a177c93d7ed018b5564b78e057fc9cdb48d
|
checksum: 10c0/ecfe56cc37db088416abb1f1b9641cc7b05b387bb532e4fe42f30a5477e55fdbb724f54e8dc5c3d8b380a5e9f80cc11426519825f8ecbcb6ca717639ba702cc8
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -3199,20 +3199,20 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:*":
|
"@types/node@npm:*":
|
||||||
version: 22.13.10
|
version: 24.0.3
|
||||||
resolution: "@types/node@npm:22.13.10"
|
resolution: "@types/node@npm:24.0.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: "npm:~6.20.0"
|
undici-types: "npm:~7.8.0"
|
||||||
checksum: 10c0/a3865f9503d6f718002374f7b87efaadfae62faa499c1a33b12c527cfb9fd86f733e1a1b026b80c5a0e4a965701174bc3305595a7d36078aa1abcf09daa5dee9
|
checksum: 10c0/9c3c4e87600d1cf11e291c2fd4bfd806a615455463c30a0ef6dc9c801b3423344d9b82b8084e3ccabce485a7421ebb61a66e9676181bd7d9aea4759998a120d5
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:^22.0.0":
|
"@types/node@npm:^22.0.0":
|
||||||
version: 22.15.18
|
version: 22.15.32
|
||||||
resolution: "@types/node@npm:22.15.18"
|
resolution: "@types/node@npm:22.15.32"
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: "npm:~6.21.0"
|
undici-types: "npm:~6.21.0"
|
||||||
checksum: 10c0/e23178c568e2dc6b93b6aa3b8dfb45f9556e527918c947fe7406a4c92d2184c7396558912400c3b1b8d0fa952ec63819aca2b8e4d3545455fc6f1e9623e09ca6
|
checksum: 10c0/63a2fa52adf1134d1b3bee8b1862d4b8e4550fffc190551068d3d41a41d9e5c0c8f1cb81faa18767b260637360f662115c26c5e4e7718868ead40c4a57cbc0e3
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -3313,10 +3313,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/semver@npm:7.5.8":
|
"@types/semver@npm:7.7.0":
|
||||||
version: 7.5.8
|
version: 7.7.0
|
||||||
resolution: "@types/semver@npm:7.5.8"
|
resolution: "@types/semver@npm:7.7.0"
|
||||||
checksum: 10c0/8663ff927234d1c5fcc04b33062cb2b9fcfbe0f5f351ed26c4d1e1581657deebd506b41ff7fdf89e787e3d33ce05854bc01686379b89e9c49b564c4cfa988efa
|
checksum: 10c0/6b5f65f647474338abbd6ee91a6bbab434662ddb8fe39464edcbcfc96484d388baad9eb506dff217b6fc1727a88894930eb1f308617161ac0f376fe06be4e1ee
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -3408,9 +3408,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@uiw/codemirror-extensions-basic-setup@npm:4.23.10":
|
"@uiw/codemirror-extensions-basic-setup@npm:4.23.13":
|
||||||
version: 4.23.10
|
version: 4.23.13
|
||||||
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10"
|
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.13"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@codemirror/autocomplete": "npm:^6.0.0"
|
"@codemirror/autocomplete": "npm:^6.0.0"
|
||||||
"@codemirror/commands": "npm:^6.0.0"
|
"@codemirror/commands": "npm:^6.0.0"
|
||||||
@ -3427,22 +3427,22 @@ __metadata:
|
|||||||
"@codemirror/search": ">=6.0.0"
|
"@codemirror/search": ">=6.0.0"
|
||||||
"@codemirror/state": ">=6.0.0"
|
"@codemirror/state": ">=6.0.0"
|
||||||
"@codemirror/view": ">=6.0.0"
|
"@codemirror/view": ">=6.0.0"
|
||||||
checksum: 10c0/64c233857b1bf878bf630297b1d4b3df14e13761ff38ceccf7a1fa21d521be288311333b7cbff927889f9a899848f4fccefd975ea5fa8d626ea4aef088f13ee8
|
checksum: 10c0/db0d1c60c8b13f69aa02b969618d3b9f5aafab23af3c8d9be9a88016aff94de1e45a1c850daf7740d265a3e5452a916112f73e5c22857346a7002c243c934159
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@uiw/codemirror-theme-duotone@npm:4.23.10":
|
"@uiw/codemirror-theme-duotone@npm:4.23.13":
|
||||||
version: 4.23.10
|
version: 4.23.13
|
||||||
resolution: "@uiw/codemirror-theme-duotone@npm:4.23.10"
|
resolution: "@uiw/codemirror-theme-duotone@npm:4.23.13"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@uiw/codemirror-themes": "npm:4.23.10"
|
"@uiw/codemirror-themes": "npm:4.23.13"
|
||||||
checksum: 10c0/058fe2ec927fb7d9fdd7a040c1fa52ed52b2b786e2917ae981a687b1aafeac26022900aac625c2018288c9bd7ab9e056854d6628eaaea46a4251b0022693df7c
|
checksum: 10c0/3c0994d7731fdad1537d66f331b3bc1f4308e9388001b3a1d168c8e4b751f28661652533b0c830b3f30456132572686a298af0672f3f36872a08fdc1cf9b1e47
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@uiw/codemirror-themes@npm:4.23.10":
|
"@uiw/codemirror-themes@npm:4.23.13":
|
||||||
version: 4.23.10
|
version: 4.23.13
|
||||||
resolution: "@uiw/codemirror-themes@npm:4.23.10"
|
resolution: "@uiw/codemirror-themes@npm:4.23.13"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@codemirror/language": "npm:^6.0.0"
|
"@codemirror/language": "npm:^6.0.0"
|
||||||
"@codemirror/state": "npm:^6.0.0"
|
"@codemirror/state": "npm:^6.0.0"
|
||||||
@ -3451,19 +3451,19 @@ __metadata:
|
|||||||
"@codemirror/language": ">=6.0.0"
|
"@codemirror/language": ">=6.0.0"
|
||||||
"@codemirror/state": ">=6.0.0"
|
"@codemirror/state": ">=6.0.0"
|
||||||
"@codemirror/view": ">=6.0.0"
|
"@codemirror/view": ">=6.0.0"
|
||||||
checksum: 10c0/29f980789a535ae021ca8d8bec3ecee4dda8cb11c5451729c04481310cc04cd7c00ddb3011137f5cc305d565bc0a3464ebd88fdde9359e296d8f0a6cf6477811
|
checksum: 10c0/4e3bc3f12681727d41ae3c60c17fab5be267cb2675ace8ce6c56c66d6453715bf6d00c9b356d27df70f2104b0eaeda8c734faf2f05b38af05e7c5b2306f2a182
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@uiw/react-codemirror@npm:4.23.10":
|
"@uiw/react-codemirror@npm:4.23.13":
|
||||||
version: 4.23.10
|
version: 4.23.13
|
||||||
resolution: "@uiw/react-codemirror@npm:4.23.10"
|
resolution: "@uiw/react-codemirror@npm:4.23.13"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.18.6"
|
"@babel/runtime": "npm:^7.18.6"
|
||||||
"@codemirror/commands": "npm:^6.1.0"
|
"@codemirror/commands": "npm:^6.1.0"
|
||||||
"@codemirror/state": "npm:^6.1.1"
|
"@codemirror/state": "npm:^6.1.1"
|
||||||
"@codemirror/theme-one-dark": "npm:^6.0.0"
|
"@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"
|
codemirror: "npm:^6.0.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@babel/runtime": ">=7.11.0"
|
"@babel/runtime": ">=7.11.0"
|
||||||
@ -3473,7 +3473,7 @@ __metadata:
|
|||||||
codemirror: ">=6.0.0"
|
codemirror: ">=6.0.0"
|
||||||
react: ">=16.8.0"
|
react: ">=16.8.0"
|
||||||
react-dom: ">=16.8.0"
|
react-dom: ">=16.8.0"
|
||||||
checksum: 10c0/cbcb928c84df2a6a03fd7789a3db317f6358961fd96197d2a3edb3ffbb00deff1cfa05fa1494c849393784bb89ae3f41425982f609f73bad0855a5e6522c328d
|
checksum: 10c0/95e79e50fa1a28cbfaf982def6ff82c5ecf18fe7eca077b4bc4cba1d43580541fcb1c38effbd299acff97f77372d8bc910642adc3d6e610c5125a9f0b85f379e
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -8548,14 +8548,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-activity-calendar@npm:^2.7.8":
|
"react-activity-calendar@npm:^2.7.11":
|
||||||
version: 2.7.8
|
version: 2.7.12
|
||||||
resolution: "react-activity-calendar@npm:2.7.8"
|
resolution: "react-activity-calendar@npm:2.7.12"
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: "npm:^4.1.0"
|
date-fns: "npm:^4.1.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
checksum: 10c0/e18d17cadc65480a8fa1df390598d7a4741a211b164091ca85fd2a21673fa667a5921f7b856e4ed92c2e8dedfb961426f163e517ea37336f2c3afb987740620d
|
checksum: 10c0/6e329c18d37e05c9f26efee3af229e9289744d9f6bac03a08b4911b62786a410d0f7cfe58ace1e52706da4adc250e82906a77ef6d2665e52d1e271edcdd8b60e
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -8630,14 +8630,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-error-boundary@npm:^5.0.0":
|
"react-error-boundary@npm:^6.0.0":
|
||||||
version: 5.0.0
|
version: 6.0.0
|
||||||
resolution: "react-error-boundary@npm:5.0.0"
|
resolution: "react-error-boundary@npm:6.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.12.5"
|
"@babel/runtime": "npm:^7.12.5"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ">=16.13.1"
|
react: ">=16.13.1"
|
||||||
checksum: 10c0/38da5e7e81016a4750d3b090e3c740c2c1125c0bb9de14e1ab92ee3b5190d34517c199935302718a24aa35d3f89081412b3444edc23f63729bde2e862a2fbfec
|
checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -8665,14 +8665,14 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-github-calendar@npm:^4.5.1":
|
"react-github-calendar@npm:^4.5.1":
|
||||||
version: 4.5.6
|
version: 4.5.7
|
||||||
resolution: "react-github-calendar@npm:4.5.6"
|
resolution: "react-github-calendar@npm:4.5.7"
|
||||||
dependencies:
|
dependencies:
|
||||||
react-activity-calendar: "npm:^2.7.8"
|
react-activity-calendar: "npm:^2.7.11"
|
||||||
react-error-boundary: "npm:^5.0.0"
|
react-error-boundary: "npm:^6.0.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
checksum: 10c0/74e995a528a3cf2a4ac4634415b614a0f6ad170f066d2d58142f15b815e8855de8b2ecf426ea054dd5d29143185dedc9a4ec7a8fa4dea4cbad6b7ef0f4411b5a
|
checksum: 10c0/a274776d8f6547d342573a42618196f436d4e555a5eb84b7b43fda0350a5174959ae74bc9d230c8cdca1b8982e4f586583200292126d4aa05cb1e3d350b579d4
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -9241,12 +9241,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"semver@npm:7.7.1":
|
"semver@npm:7.7.2":
|
||||||
version: 7.7.1
|
version: 7.7.2
|
||||||
resolution: "semver@npm:7.7.1"
|
resolution: "semver@npm:7.7.2"
|
||||||
bin:
|
bin:
|
||||||
semver: bin/semver.js
|
semver: bin/semver.js
|
||||||
checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958
|
checksum: 10c0/aca305edfbf2383c22571cb7714f48cadc7ac95371b4b52362fb8eeffdfbc0de0669368b82b2b15978f8848f01d7114da65697e56cd8c37b0dab8c58e543f9ea
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -10221,13 +10221,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"undici-types@npm:~6.21.0":
|
||||||
version: 6.21.0
|
version: 6.21.0
|
||||||
resolution: "undici-types@npm:6.21.0"
|
resolution: "undici-types@npm:6.21.0"
|
||||||
@ -10235,6 +10228,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"unified@npm:^10.0.0":
|
||||||
version: 10.1.2
|
version: 10.1.2
|
||||||
resolution: "unified@npm:10.1.2"
|
resolution: "unified@npm:10.1.2"
|
||||||
@ -10369,10 +10369,10 @@ __metadata:
|
|||||||
"@types/react-router-dom": "npm:5.3.3"
|
"@types/react-router-dom": "npm:5.3.3"
|
||||||
"@types/react-table": "npm:7.7.20"
|
"@types/react-table": "npm:7.7.20"
|
||||||
"@types/react-test-renderer": "npm:18.3.1"
|
"@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"
|
"@types/uuid": "npm:^9.0.0"
|
||||||
"@uiw/codemirror-theme-duotone": "npm:4.23.10"
|
"@uiw/codemirror-theme-duotone": "npm:4.23.13"
|
||||||
"@uiw/react-codemirror": "npm:4.23.10"
|
"@uiw/react-codemirror": "npm:4.23.13"
|
||||||
"@unleash/proxy-client-react": "npm:^5.0.0"
|
"@unleash/proxy-client-react": "npm:^5.0.0"
|
||||||
"@vitejs/plugin-react": "npm:4.3.4"
|
"@vitejs/plugin-react": "npm:4.3.4"
|
||||||
cartesian: "npm:^1.0.1"
|
cartesian: "npm:^1.0.1"
|
||||||
@ -10422,7 +10422,7 @@ __metadata:
|
|||||||
react-table: "npm:7.8.0"
|
react-table: "npm:7.8.0"
|
||||||
react-test-renderer: "npm:18.3.1"
|
react-test-renderer: "npm:18.3.1"
|
||||||
sass: "npm:1.85.1"
|
sass: "npm:1.85.1"
|
||||||
semver: "npm:7.7.1"
|
semver: "npm:7.7.2"
|
||||||
swr: "npm:2.3.3"
|
swr: "npm:2.3.3"
|
||||||
tss-react: "npm:4.9.15"
|
tss-react: "npm:4.9.15"
|
||||||
typescript: "npm:5.8.3"
|
typescript: "npm:5.8.3"
|
||||||
|
18
package.json
18
package.json
@ -2,7 +2,7 @@
|
|||||||
"name": "unleash-server",
|
"name": "unleash-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.",
|
"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": [
|
"keywords": [
|
||||||
"unleash",
|
"unleash",
|
||||||
"feature flag",
|
"feature flag",
|
||||||
@ -129,7 +129,7 @@
|
|||||||
"ts-toolbelt": "^9.6.0",
|
"ts-toolbelt": "^9.6.0",
|
||||||
"type-is": "^1.6.18",
|
"type-is": "^1.6.18",
|
||||||
"ulidx": "^2.4.1",
|
"ulidx": "^2.4.1",
|
||||||
"unleash-client": "^6.6.0",
|
"unleash-client": "^6.7.0-beta.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -141,25 +141,25 @@
|
|||||||
"@swc/core": "1.11.31",
|
"@swc/core": "1.11.31",
|
||||||
"@types/bcryptjs": "2.4.6",
|
"@types/bcryptjs": "2.4.6",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.23",
|
||||||
"@types/express-session": "1.18.1",
|
"@types/express-session": "1.18.2",
|
||||||
"@types/faker": "5.5.9",
|
"@types/faker": "5.5.9",
|
||||||
"@types/hash-sum": "^1.0.0",
|
"@types/hash-sum": "^1.0.0",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/lodash.groupby": "4.6.9",
|
"@types/lodash.groupby": "4.6.9",
|
||||||
"@types/lodash.isequal": "^4.5.8",
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
"@types/memoizee": "0.4.11",
|
"@types/memoizee": "0.4.12",
|
||||||
"@types/mime": "4.0.0",
|
"@types/mime": "4.0.0",
|
||||||
"@types/murmurhash3js": "^3.0.7",
|
"@types/murmurhash3js": "^3.0.7",
|
||||||
"@types/mustache": "^4.2.5",
|
"@types/mustache": "^4.2.5",
|
||||||
"@types/node": "22.15.18",
|
"@types/node": "22.15.18",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/owasp-password-strength-test": "1.3.2",
|
"@types/owasp-password-strength-test": "1.3.2",
|
||||||
"@types/pg": "8.15.2",
|
"@types/pg": "8.15.4",
|
||||||
"@types/semver": "7.5.8",
|
"@types/semver": "7.7.0",
|
||||||
"@types/slug": "^5.0.8",
|
"@types/slug": "^5.0.8",
|
||||||
"@types/stoppable": "1.1.3",
|
"@types/stoppable": "1.1.3",
|
||||||
"@types/supertest": "6.0.2",
|
"@types/supertest": "6.0.3",
|
||||||
"@types/type-is": "1.6.7",
|
"@types/type-is": "1.6.7",
|
||||||
"@types/uuid": "9.0.8",
|
"@types/uuid": "9.0.8",
|
||||||
"@vitest/coverage-v8": "^3.1.3",
|
"@vitest/coverage-v8": "^3.1.3",
|
||||||
@ -167,7 +167,7 @@
|
|||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"coveralls": "^3.1.1",
|
"coveralls": "^3.1.1",
|
||||||
"del-cli": "5.1.0",
|
"del-cli": "6.0.0",
|
||||||
"faker": "5.5.3",
|
"faker": "5.5.3",
|
||||||
"fast-check": "3.23.2",
|
"fast-check": "3.23.2",
|
||||||
"fetch-mock": "^12.0.0",
|
"fetch-mock": "^12.0.0",
|
||||||
|
@ -11,7 +11,7 @@ services:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
|
|
||||||
pgadmin:
|
pgadmin:
|
||||||
image: dpage/pgadmin4:9.3
|
image: dpage/pgadmin4:9.4
|
||||||
environment:
|
environment:
|
||||||
PGADMIN_DEFAULT_EMAIL: 'admin@admin.com'
|
PGADMIN_DEFAULT_EMAIL: 'admin@admin.com'
|
||||||
PGADMIN_DEFAULT_PASSWORD: 'admin'
|
PGADMIN_DEFAULT_PASSWORD: 'admin'
|
||||||
|
@ -101,6 +101,7 @@ exports[`should create default config 1`] = `
|
|||||||
"preHook": undefined,
|
"preHook": undefined,
|
||||||
"preRouterHook": undefined,
|
"preRouterHook": undefined,
|
||||||
"prometheusApi": undefined,
|
"prometheusApi": undefined,
|
||||||
|
"prometheusImpactMetricsApi": undefined,
|
||||||
"publicFolder": undefined,
|
"publicFolder": undefined,
|
||||||
"rateLimiting": {
|
"rateLimiting": {
|
||||||
"callSignalEndpointMaxPerSecond": 1,
|
"callSignalEndpointMaxPerSecond": 1,
|
||||||
|
@ -773,6 +773,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
|||||||
defaultDaysToBeConsideredInactive,
|
defaultDaysToBeConsideredInactive,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const prometheusImpactMetricsApi =
|
||||||
|
options.prometheusImpactMetricsApi ||
|
||||||
|
process.env.PROMETHEUS_IMPACT_METRICS_API;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
session,
|
session,
|
||||||
@ -804,6 +808,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
|
|||||||
clientFeatureCaching,
|
clientFeatureCaching,
|
||||||
accessControlMaxAge,
|
accessControlMaxAge,
|
||||||
prometheusApi,
|
prometheusApi,
|
||||||
|
prometheusImpactMetricsApi,
|
||||||
publicFolder: options.publicFolder,
|
publicFolder: options.publicFolder,
|
||||||
disableScheduler: options.disableScheduler,
|
disableScheduler: options.disableScheduler,
|
||||||
isEnterprise: isEnterprise,
|
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> {
|
async bulkUpsert(instances: INewClientInstance[]): Promise<void> {
|
||||||
const stopTimer = this.metricTimer('bulkUpsert');
|
const stopTimer = this.metricTimer('bulkUpsert');
|
||||||
|
|
||||||
|
@ -222,7 +222,7 @@ export default class EventService {
|
|||||||
if (parsed) queryParams.push(parsed);
|
if (parsed) queryParams.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
['project', 'type', 'environment'].forEach((field) => {
|
['project', 'type', 'environment', 'id'].forEach((field) => {
|
||||||
if (params[field]) {
|
if (params[field]) {
|
||||||
const parsed = parseSearchOperatorValue(field, params[field]);
|
const parsed = parseSearchOperatorValue(field, params[field]);
|
||||||
if (parsed) queryParams.push(parsed);
|
if (parsed) queryParams.push(parsed);
|
||||||
|
@ -918,11 +918,6 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
const { shouldActivateDisabledStrategies } = req.query;
|
const { shouldActivateDisabledStrategies } = req.query;
|
||||||
const { features } = req.body;
|
const { features } = req.body;
|
||||||
|
|
||||||
if (this.flagResolver.isEnabled('disableBulkToggle')) {
|
|
||||||
res.status(403).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.transactionalFeatureToggleService.transactional((service) =>
|
await this.transactionalFeatureToggleService.transactional((service) =>
|
||||||
service.bulkUpdateEnabled(
|
service.bulkUpdateEnabled(
|
||||||
projectId,
|
projectId,
|
||||||
@ -950,11 +945,6 @@ export default class ProjectFeaturesController extends Controller {
|
|||||||
const { shouldActivateDisabledStrategies } = req.query;
|
const { shouldActivateDisabledStrategies } = req.query;
|
||||||
const { features } = req.body;
|
const { features } = req.body;
|
||||||
|
|
||||||
if (this.flagResolver.isEnabled('disableBulkToggle')) {
|
|
||||||
res.status(403).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.transactionalFeatureToggleService.transactional((service) =>
|
await this.transactionalFeatureToggleService.transactional((service) =>
|
||||||
service.bulkUpdateEnabled(
|
service.bulkUpdateEnabled(
|
||||||
projectId,
|
projectId,
|
||||||
|
@ -6,7 +6,7 @@ import type {
|
|||||||
IClientMetricsEnv,
|
IClientMetricsEnv,
|
||||||
IClientMetricsStoreV2,
|
IClientMetricsStoreV2,
|
||||||
} from './client-metrics-store-v2-type.js';
|
} 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 { compareAsc, secondsToMilliseconds } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
CLIENT_METRICS,
|
CLIENT_METRICS,
|
||||||
@ -30,6 +30,11 @@ import {
|
|||||||
MAX_UNKNOWN_FLAGS,
|
MAX_UNKNOWN_FLAGS,
|
||||||
type UnknownFlagsService,
|
type UnknownFlagsService,
|
||||||
} from '../unknown-flags/unknown-flags-service.js';
|
} 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 {
|
export default class ClientMetricsServiceV2 {
|
||||||
private config: IUnleashConfig;
|
private config: IUnleashConfig;
|
||||||
@ -46,6 +51,8 @@ export default class ClientMetricsServiceV2 {
|
|||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
|
private impactMetricsTranslator: MetricsTranslator;
|
||||||
|
|
||||||
private cachedFeatureNames: () => Promise<string[]>;
|
private cachedFeatureNames: () => Promise<string[]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -69,6 +76,7 @@ export default class ClientMetricsServiceV2 {
|
|||||||
maxAge: secondsToMilliseconds(10),
|
maxAge: secondsToMilliseconds(10),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
this.impactMetricsTranslator = new MetricsTranslator(impactRegister);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearMetrics(hoursAgo: number) {
|
async clearMetrics(hoursAgo: number) {
|
||||||
@ -187,6 +195,11 @@ export default class ClientMetricsServiceV2 {
|
|||||||
this.lastSeenService.updateLastSeen(metrics);
|
this.lastSeenService.updateLastSeen(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerImpactMetrics(impactMetrics: Metric[]) {
|
||||||
|
const value = await impactMetricsSchema.validateAsync(impactMetrics);
|
||||||
|
this.impactMetricsTranslator.translateMetrics(value);
|
||||||
|
}
|
||||||
|
|
||||||
async registerClientMetrics(
|
async registerClientMetrics(
|
||||||
data: ClientMetricsSchema,
|
data: ClientMetricsSchema,
|
||||||
clientIp: string,
|
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';
|
import { Counter, Gauge, type Registry } from 'prom-client';
|
||||||
|
|
||||||
interface MetricSample {
|
export interface MetricSample {
|
||||||
labels?: Record<string, string | number>;
|
labels?: Record<string, string | number>;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Metric {
|
export interface Metric {
|
||||||
name: string;
|
name: string;
|
||||||
help: string;
|
help: string;
|
||||||
type: 'counter' | 'gauge';
|
type: 'counter' | 'gauge';
|
||||||
|
@ -101,21 +101,12 @@ export default class ClientInstanceService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const value = await clientMetricsSchema.validateAsync(data);
|
const value = await clientMetricsSchema.validateAsync(data);
|
||||||
|
|
||||||
if (this.flagResolver.isEnabled('lastSeenBulkQuery')) {
|
|
||||||
this.seenClients[this.clientKey(value)] = {
|
this.seenClients[this.clientKey(value)] = {
|
||||||
appName: value.appName,
|
appName: value.appName,
|
||||||
instanceId: value.instanceId,
|
instanceId: value.instanceId,
|
||||||
environment: value.environment,
|
environment: value.environment,
|
||||||
clientIp: clientIp,
|
clientIp: clientIp,
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
await this.clientInstanceStore.setLastSeen({
|
|
||||||
appName: value.appName,
|
|
||||||
instanceId: value.instanceId,
|
|
||||||
environment: value.environment,
|
|
||||||
clientIp: clientIp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public registerFrontendClient(data: IFrontendClientApp): void {
|
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 { CustomMetricsSchema } from '../../../openapi/spec/custom-metrics-schema.js';
|
||||||
import type { StoredCustomMetric } from '../custom/custom-metrics-store.js';
|
import type { StoredCustomMetric } from '../custom/custom-metrics-store.js';
|
||||||
import type { CustomMetricsService } from '../custom/custom-metrics-service.js';
|
import type { CustomMetricsService } from '../custom/custom-metrics-service.js';
|
||||||
|
import type { MetricsTranslator } from '../impact/metrics-translator.js';
|
||||||
|
|
||||||
export default class ClientMetricsController extends Controller {
|
export default class ClientMetricsController extends Controller {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@ -39,6 +40,8 @@ export default class ClientMetricsController extends Controller {
|
|||||||
|
|
||||||
customMetricsService: CustomMetricsService;
|
customMetricsService: CustomMetricsService;
|
||||||
|
|
||||||
|
metricsTranslator: MetricsTranslator;
|
||||||
|
|
||||||
flagResolver: IFlagResolver;
|
flagResolver: IFlagResolver;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -150,16 +153,25 @@ export default class ClientMetricsController extends Controller {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const { body: data, ip: clientIp, user } = req;
|
const { body: data, ip: clientIp, user } = req;
|
||||||
data.environment = this.metricsV2.resolveMetricsEnvironment(
|
const { impactMetrics, ...metricsData } = data;
|
||||||
user,
|
metricsData.environment =
|
||||||
data,
|
this.metricsV2.resolveMetricsEnvironment(user, metricsData);
|
||||||
);
|
|
||||||
await this.clientInstanceService.registerInstance(
|
await this.clientInstanceService.registerInstance(
|
||||||
data,
|
metricsData,
|
||||||
clientIp,
|
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.getHeaderNames().forEach((header) =>
|
||||||
res.removeHeader(header),
|
res.removeHeader(header),
|
||||||
);
|
);
|
||||||
|
@ -85,6 +85,35 @@ export const customMetricsSchema = joi
|
|||||||
metrics: joi.array().items(customMetricSchema).required(),
|
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
|
export const batchMetricsSchema = joi
|
||||||
.object()
|
.object()
|
||||||
.options({ stripUnknown: true })
|
.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).',
|
'Find events by a free-text search query. The query will be matched against the event data payload (if any).',
|
||||||
in: 'query',
|
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',
|
name: 'feature',
|
||||||
schema: {
|
schema: {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import stoppable, { type StoppableServer } from 'stoppable';
|
import stoppable, { type StoppableServer } from 'stoppable';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import version from './util/version.js';
|
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 getApp from './app.js';
|
||||||
import type MetricsMonitor from './metrics.js';
|
import type MetricsMonitor from './metrics.js';
|
||||||
import { createMetricsMonitor } from './metrics.js';
|
import { createMetricsMonitor } from './metrics.js';
|
||||||
@ -336,6 +336,7 @@ async function start(
|
|||||||
if (config.db.disableMigration) {
|
if (config.db.disableMigration) {
|
||||||
logger.info('DB migration: disabled');
|
logger.info('DB migration: disabled');
|
||||||
} else {
|
} else {
|
||||||
|
if (await requiresMigration(config)) {
|
||||||
logger.info('DB migration: start');
|
logger.info('DB migration: start');
|
||||||
if (config.flagResolver.isEnabled('migrationLock')) {
|
if (config.flagResolver.isEnabled('migrationLock')) {
|
||||||
logger.info('Running migration with lock');
|
logger.info('Running migration with lock');
|
||||||
@ -351,6 +352,9 @@ async function start(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info('DB migration: end');
|
logger.info('DB migration: end');
|
||||||
|
} else {
|
||||||
|
logger.info('DB migration: no migration needed');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to migrate db', 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';
|
'Unleash - Scheduled changes can no longer be applied';
|
||||||
const SCHEDULED_EXECUTION_FAILED_SUBJECT =
|
const SCHEDULED_EXECUTION_FAILED_SUBJECT =
|
||||||
'Unleash - Scheduled change request could not be applied';
|
'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 const MAIL_ACCEPTED = '250 Accepted';
|
||||||
|
|
||||||
export type ChangeRequestScheduleConflictData =
|
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(
|
async sendScheduledExecutionFailedEmail(
|
||||||
recipient: string,
|
recipient: string,
|
||||||
changeRequestLink: string,
|
changeRequestLink: string,
|
||||||
|
@ -17,7 +17,6 @@ export type IFlagKey =
|
|||||||
| 'migrationLock'
|
| 'migrationLock'
|
||||||
| 'demo'
|
| 'demo'
|
||||||
| 'googleAuthEnabled'
|
| 'googleAuthEnabled'
|
||||||
| 'disableBulkToggle'
|
|
||||||
| 'advancedPlayground'
|
| 'advancedPlayground'
|
||||||
| 'filterInvalidClientMetrics'
|
| 'filterInvalidClientMetrics'
|
||||||
| 'disableMetrics'
|
| 'disableMetrics'
|
||||||
@ -57,7 +56,6 @@ export type IFlagKey =
|
|||||||
| 'edgeObservability'
|
| 'edgeObservability'
|
||||||
| 'registerFrontendClient'
|
| 'registerFrontendClient'
|
||||||
| 'reportUnknownFlags'
|
| 'reportUnknownFlags'
|
||||||
| 'lastSeenBulkQuery'
|
|
||||||
| 'lifecycleMetrics'
|
| 'lifecycleMetrics'
|
||||||
| 'customMetrics'
|
| 'customMetrics'
|
||||||
| 'impactMetrics'
|
| 'impactMetrics'
|
||||||
@ -105,10 +103,6 @@ const flags: IFlags = {
|
|||||||
process.env.GOOGLE_AUTH_ENABLED,
|
process.env.GOOGLE_AUTH_ENABLED,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
disableBulkToggle: parseEnvVarBoolean(
|
|
||||||
process.env.DISABLE_BULK_TOGGLE,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
filterInvalidClientMetrics: parseEnvVarBoolean(
|
filterInvalidClientMetrics: parseEnvVarBoolean(
|
||||||
process.env.FILTER_INVALID_CLIENT_METRICS,
|
process.env.FILTER_INVALID_CLIENT_METRICS,
|
||||||
false,
|
false,
|
||||||
@ -272,10 +266,6 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
|
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
lastSeenBulkQuery: parseEnvVarBoolean(
|
|
||||||
process.env.UNLEASH_EXPERIMENTAL_LAST_SEEN_BULK_QUERY,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
lifecycleMetrics: parseEnvVarBoolean(
|
lifecycleMetrics: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_LIFECYCLE_METRICS,
|
process.env.UNLEASH_EXPERIMENTAL_LIFECYCLE_METRICS,
|
||||||
false,
|
false,
|
||||||
@ -319,10 +309,19 @@ export interface IFlagResolver {
|
|||||||
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
|
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
|
||||||
getVariant: (expName: IFlagKey, context?: IFlagContext) => Variant;
|
getVariant: (expName: IFlagKey, context?: IFlagContext) => Variant;
|
||||||
getStaticContext: () => IFlagContext;
|
getStaticContext: () => IFlagContext;
|
||||||
|
impactMetrics?: IImpactMetricsResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExternalFlagResolver {
|
export interface IExternalFlagResolver {
|
||||||
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
|
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
|
||||||
getVariant: (flagName: IFlagKey, context?: IFlagContext) => Variant;
|
getVariant: (flagName: IFlagKey, context?: IFlagContext) => Variant;
|
||||||
getStaticContext: () => IFlagContext;
|
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>;
|
clientFeatureCaching?: Partial<IClientCachingOption>;
|
||||||
accessControlMaxAge?: number;
|
accessControlMaxAge?: number;
|
||||||
prometheusApi?: string;
|
prometheusApi?: string;
|
||||||
|
prometheusImpactMetricsApi?: string;
|
||||||
publicFolder?: string;
|
publicFolder?: string;
|
||||||
disableScheduler?: boolean;
|
disableScheduler?: boolean;
|
||||||
metricsRateLimiting?: Partial<IMetricsRateLimiting>;
|
metricsRateLimiting?: Partial<IMetricsRateLimiting>;
|
||||||
@ -288,6 +289,7 @@ export interface IUnleashConfig {
|
|||||||
clientFeatureCaching: IClientCachingOption;
|
clientFeatureCaching: IClientCachingOption;
|
||||||
accessControlMaxAge: number;
|
accessControlMaxAge: number;
|
||||||
prometheusApi?: string;
|
prometheusApi?: string;
|
||||||
|
prometheusImpactMetricsApi?: string;
|
||||||
publicFolder?: string;
|
publicFolder?: string;
|
||||||
disableScheduler?: boolean;
|
disableScheduler?: boolean;
|
||||||
isEnterprise: boolean;
|
isEnterprise: boolean;
|
||||||
|
@ -18,11 +18,6 @@ export interface IClientInstanceStore
|
|||||||
Pick<INewClientInstance, 'appName' | 'instanceId'>
|
Pick<INewClientInstance, 'appName' | 'instanceId'>
|
||||||
> {
|
> {
|
||||||
bulkUpsert(instances: INewClientInstance[]): Promise<void>;
|
bulkUpsert(instances: INewClientInstance[]): Promise<void>;
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
* `bulkUpsert` is beeing used instead. remove with `lastSeenBulkQuery` flag
|
|
||||||
*/
|
|
||||||
setLastSeen(INewClientInstance): Promise<void>;
|
|
||||||
insert(details: INewClientInstance): Promise<void>;
|
insert(details: INewClientInstance): Promise<void>;
|
||||||
getByAppName(appName: string): Promise<IClientInstance[]>;
|
getByAppName(appName: string): Promise<IClientInstance[]>;
|
||||||
getRecentByAppNameAndEnvironment(
|
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';
|
import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type.js';
|
||||||
|
|
||||||
export interface IEventSearchParams {
|
export interface IEventSearchParams {
|
||||||
|
id?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
feature?: 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
|
// This exists to ease testing
|
||||||
export async function resetDb({ db }: IUnleashConfig): Promise<void> {
|
export async function resetDb({ db }: IUnleashConfig): Promise<void> {
|
||||||
return noDatabaseUrl(async () => {
|
return noDatabaseUrl(async () => {
|
||||||
|
@ -56,6 +56,7 @@ process.nextTick(async () => {
|
|||||||
customMetrics: true,
|
customMetrics: true,
|
||||||
lifecycleMetrics: true,
|
lifecycleMetrics: true,
|
||||||
improvedJsonDiff: true,
|
improvedJsonDiff: true,
|
||||||
|
impactMetrics: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
@ -69,6 +70,7 @@ process.nextTick(async () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
prometheusImpactMetricsApi: 'http://localhost:9090',
|
||||||
/* can be tweaked to control configuration caching for /api/client/features
|
/* can be tweaked to control configuration caching for /api/client/features
|
||||||
clientFeatureCaching: {
|
clientFeatureCaching: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
@ -618,3 +618,103 @@ test('should filter events by environment using IS_ANY_OF', async () => {
|
|||||||
total: 2,
|
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(updatedEvent!.id).toBeGreaterThan(taggedEvent!.id);
|
||||||
expect(segmentEvent!.id).toBeGreaterThan(updatedEvent!.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[]> {
|
async getBySdkName(sdkName: string): Promise<IClientInstance[]> {
|
||||||
return this.instances.filter((instance) =>
|
return this.instances.filter((instance) =>
|
||||||
instance.sdkVersion?.startsWith(sdkName),
|
instance.sdkVersion?.startsWith(sdkName),
|
||||||
|
Loading…
Reference in New Issue
Block a user