diff --git a/.github/workflows/build_prs_jest_report.yaml b/.github/workflows/build_prs_jest_report.yaml index 7a34807384..ca7c5ee8c8 100644 --- a/.github/workflows/build_prs_jest_report.yaml +++ b/.github/workflows/build_prs_jest_report.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull_requests: write + pull-requests: write name: build # temporary solution to trick branch protection rules services: diff --git a/.github/workflows/hypermod.yml b/.github/workflows/hypermod.yml index f91a0d82ef..88f52437f7 100644 --- a/.github/workflows/hypermod.yml +++ b/.github/workflows/hypermod.yml @@ -12,7 +12,7 @@ jobs: permissions: write-all runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Run Hypermod CLI uses: hypermod-io/action@v1 with: diff --git a/frontend/package.json b/frontend/package.json index 83d3c3165a..f847c9c2b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@codemirror/lang-json": "6.0.1", + "@codemirror/lang-json": "6.0.2", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", "@mui/icons-material": "5.15.3", @@ -59,8 +59,8 @@ "@types/lodash.mapvalues": "^4.6.9", "@types/lodash.omit": "4.5.9", "@types/node": "^22.0.0", - "@types/react": "18.3.18", - "@types/react-dom": "18.3.5", + "@types/react": "18.3.23", + "@types/react-dom": "18.3.7", "@types/react-router-dom": "5.3.3", "@types/react-table": "7.7.20", "@types/react-test-renderer": "18.3.1", @@ -116,7 +116,7 @@ "sass": "1.85.1", "semver": "7.7.2", "swr": "2.3.3", - "tss-react": "4.9.15", + "tss-react": "4.9.18", "typescript": "5.8.3", "unleash-proxy-client": "^3.7.3", "use-query-params": "^2.2.1", @@ -136,7 +136,7 @@ "vite": "5.4.19", "semver": "7.7.2", "ws": "^8.18.0", - "@types/react": "18.3.18" + "@types/react": "18.3.23" }, "jest": { "moduleNameMapper": { diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx new file mode 100644 index 0000000000..da1bb88b26 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/DraftChangeRequestActions/DraftChangeRequestActions.tsx @@ -0,0 +1,182 @@ +import type { FC } from 'react'; +import { + styled, + Button, + Checkbox, + TextField, + useTheme, + type AutocompleteChangeReason, + type FilterOptionsState, +} from '@mui/material'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import AutocompleteVirtual from 'component/common/AutocompleteVirtual/AutcompleteVirtual'; +import { caseInsensitiveSearch } from 'utils/search'; +import type { ChangeRequestType } from 'component/changeRequest/changeRequest.types'; +import { changesCount } from '../../changesCount.js'; +import { + type AvailableReviewerSchema, + useAvailableChangeRequestReviewers, +} from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.js'; + +const SubmitChangeRequestButton: FC<{ + onClick: () => void; + count: number; + disabled?: boolean; +}> = ({ onClick, count, disabled = false }) => ( + +); + +const StyledTags = styled('div')(({ theme }) => ({ + paddingLeft: theme.spacing(1), +})); + +const StrechedLi = styled('li')({ width: '100%' }); + +const StyledOption = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + '& > span:first-of-type': { + color: theme.palette.text.secondary, + }, +})); + +const renderOption = ( + props: React.HTMLAttributes, + option: AvailableReviewerSchema, + { selected }: { selected: boolean }, +) => ( + + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {option.name || option.username} + + {option.name && option.username + ? option.username + : option.email} + + + +); + +const renderTags = (value: AvailableReviewerSchema[]) => ( + + {value.length > 1 + ? `${value.length} users selected` + : value[0].name || value[0].username || value[0].email} + +); + +export const DraftChangeRequestActions: FC<{ + environmentChangeRequest: ChangeRequestType; + reviewers: AvailableReviewerSchema[]; + setReviewers: React.Dispatch< + React.SetStateAction + >; + onReview: (changeState: (project: string) => Promise) => void; + onDiscard: (id: number) => void; + sendToReview: (project: string) => Promise; + disabled?: boolean; + setDisabled: (disabled: boolean) => void; +}> = ({ + environmentChangeRequest, + reviewers, + setReviewers, + onReview, + onDiscard, + sendToReview, + disabled, + setDisabled, +}) => { + const theme = useTheme(); + const { reviewers: availableReviewers, loading: isLoading } = + useAvailableChangeRequestReviewers( + environmentChangeRequest.project, + environmentChangeRequest.environment, + ); + + const autoCompleteChange = ( + event: React.SyntheticEvent, + newValue: AvailableReviewerSchema[], + reason: AutocompleteChangeReason, + ) => { + if ( + event.type === 'keydown' && + (event as React.KeyboardEvent).key === 'Backspace' && + reason === 'removeOption' + ) { + return; + } + setReviewers(newValue); + }; + + const filterOptions = ( + options: AvailableReviewerSchema[], + { inputValue }: FilterOptionsState, + ) => + options.filter( + ({ name, username, email }) => + caseInsensitiveSearch(inputValue, email) || + caseInsensitiveSearch(inputValue, name) || + caseInsensitiveSearch(inputValue, username), + ); + + return ( + <> + option.id === value.id} + getOptionLabel={(option: AvailableReviewerSchema) => + option.email || option.name || option.username || '' + } + renderInput={(params) => ( + + )} + renderTags={(value) => renderTags(value)} + noOptionsText={isLoading ? 'Loading…' : 'No options'} + /> + onReview(sendToReview)} + count={changesCount(environmentChangeRequest)} + disabled={disabled} + /> + + + + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx index 23aa8d456e..3d48ecae1b 100644 --- a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequest.tsx @@ -24,6 +24,9 @@ import Input from 'component/common/Input/Input'; import { ChangeRequestTitle } from './ChangeRequestTitle.tsx'; import { UpdateCount } from 'component/changeRequest/UpdateCount'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { DraftChangeRequestActions } from '../DraftChangeRequestActions/DraftChangeRequestActions.tsx'; +import type { AvailableReviewerSchema } from 'hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts'; const SubmitChangeRequestButton: FC<{ onClick: () => void; @@ -71,11 +74,22 @@ export const EnvironmentChangeRequest: FC<{ const [commentText, setCommentText] = useState(''); const { user } = useAuthUser(); const [title, setTitle] = useState(environmentChangeRequest.title); - const { changeState } = useChangeRequestApi(); + const { changeState, updateRequestedReviewers } = useChangeRequestApi(); + const [reviewers, setReviewers] = useState([]); + const [disabled, setDisabled] = useState(false); + const approversEnabled = useUiFlag('changeRequestApproverEmails'); const sendToReview = async (project: string) => { setDisabled(true); try { + if (reviewers && reviewers.length > 0) { + await updateRequestedReviewers( + project, + environmentChangeRequest.id, + reviewers.map((reviewer) => reviewer.id), + ); + } + await changeState(project, environmentChangeRequest.id, 'Draft', { state: 'In review', comment: commentText, @@ -153,27 +167,50 @@ export const EnvironmentChangeRequest: FC<{ - onReview(sendToReview)} - count={changesCount( - environmentChangeRequest, - )} - disabled={disabled} - /> + + } + elseShow={ + <> + + onReview(sendToReview) + } + count={changesCount( + environmentChangeRequest, + )} + disabled={disabled} + /> - - + + + } + /> } /> { abortController.abort(); diff --git a/frontend/src/component/demo/demo-topics.tsx b/frontend/src/component/demo/demo-topics.tsx index c6e2dac993..767e61408a 100644 --- a/frontend/src/component/demo/demo-topics.tsx +++ b/frontend/src/component/demo/demo-topics.tsx @@ -12,6 +12,13 @@ export interface ITutorialTopicStep extends Step { backCloseModal?: boolean; backCollapseExpanded?: boolean; preventDefault?: boolean; + onStep?: (params: { + el: HTMLElement; + index: number; + next: (i?: number) => void; + step: ITutorialTopicStep; + signal: AbortSignal; + }) => void; anyClick?: boolean; optional?: boolean; focus?: boolean | string; @@ -246,6 +253,16 @@ export const TOPICS: ITutorialTopic[] = [ placement: 'right', backCloseModal: true, }, + { + title: 'Add constraint value', + target: 'button[data-testid="CONSTRAINT_ADD_VALUES_BUTTON"]', + content: ( + + Add a new constraint value by using this button. + + ), + optional: true, + }, { title: 'Input value', target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]', @@ -273,15 +290,29 @@ export const TOPICS: ITutorialTopic[] = [ placement: 'right', nextButton: true, focus: 'input', + onStep: ({ el, next, index, signal }) => { + const input = el.querySelector('input'); + + input?.addEventListener( + 'keydown', + (e) => { + if (e.key === 'Enter' && input.value.trim()) { + next(index); + } + }, + { signal }, + ); + }, }, { title: 'Add value', - target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]', + target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]:not(:disabled)', content: ( Add the constraint value by using this button. ), + optional: true, }, { title: 'Save constraint setup', @@ -623,6 +654,16 @@ export const TOPICS: ITutorialTopic[] = [ placement: 'right', backCloseModal: true, }, + { + title: 'Add constraint value', + target: 'button[data-testid="CONSTRAINT_ADD_VALUES_BUTTON"]', + content: ( + + Add a new constraint value by using this button. + + ), + optional: true, + }, { title: 'Input value', target: 'div[data-testid="CONSTRAINT_VALUES_INPUT"]', @@ -650,15 +691,29 @@ export const TOPICS: ITutorialTopic[] = [ placement: 'right', nextButton: true, focus: 'input', + onStep: ({ el, next, index, signal }) => { + const input = el.querySelector('input'); + + input?.addEventListener( + 'keydown', + (e) => { + if (e.key === 'Enter' && input.value.trim()) { + next(index); + } + }, + { signal }, + ); + }, }, { title: 'Add value', - target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]', + target: 'button[data-testid="CONSTRAINT_VALUES_ADD_BUTTON"]:not(:disabled)', content: ( Add the constraint value by using this button. ), + optional: true, }, { title: 'Save constraint setup', diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx index 1b441077d6..6c84e92b86 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesPopover.tsx @@ -130,6 +130,7 @@ export const AddValuesPopover: FC = ({ inputProps={{ ...inputProps, }} + data-testid='CONSTRAINT_VALUES_INPUT' /> = ({ size='small' color='primary' disabled={!inputValue?.trim()} + data-testid='CONSTRAINT_VALUES_ADD_BUTTON' > Add diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesWidget.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesWidget.tsx index 52dc0ed1a8..cb24686b70 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesWidget.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/AddValuesWidget.tsx @@ -74,6 +74,7 @@ export const AddValuesWidget = forwardRef( ref={positioningRef} onClick={() => setOpen(true)} type='button' + data-testid='CONSTRAINT_ADD_VALUES_BUTTON' > Add values diff --git a/frontend/src/component/insights/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx b/frontend/src/component/insights/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx index ceef9f659d..22bae055ed 100644 --- a/frontend/src/component/insights/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx +++ b/frontend/src/component/insights/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx @@ -137,6 +137,11 @@ export const ProjectHealthChart: FC = ({ : 'health', xAxisKey: 'date', }, + plugins: { + legend: { + display: !isAggregate, + }, + }, scales: { y: { min: 0, diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx index f00c52d368..92c133f8cc 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectStatusModal.tsx @@ -9,7 +9,6 @@ import { ProjectHealthGrid } from './ProjectHealthGrid.tsx'; import { useFeedback } from 'component/feedbackNew/useFeedback'; import FeedbackIcon from '@mui/icons-material/ChatOutlined'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import { useUiFlag } from 'hooks/useUiFlag'; const ModalContentContainer = styled('section')(({ theme }) => ({ minHeight: '100vh', @@ -18,7 +17,7 @@ const ModalContentContainer = styled('section')(({ theme }) => ({ backgroundColor: theme.palette.background.default, display: 'flex', flexFlow: 'column', - gap: theme.spacing(2), + gap: theme.spacing(2.5), paddingInline: theme.spacing(4), paddingBlock: theme.spacing(3.75), })); @@ -141,7 +140,6 @@ export const ProjectStatusModal = ({ open, onClose, onFollowLink }: Props) => { }); }; const { isOss } = useUiConfig(); - const healthToDebtEnabled = useUiFlag('healthToTechDebt'); return ( { - - {healthToDebtEnabled ? 'Technical debt' : 'Health'} - {!isOss() && ( diff --git a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts index ecc308bed4..a8cd0a0485 100644 --- a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts +++ b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts @@ -190,6 +190,23 @@ export const useChangeRequestApi = () => { return makeRequest(req.caller, req.id); }; + const updateRequestedReviewers = async ( + project: string, + changeRequestId: number, + reviewers: string[], + ) => { + trackEvent('change_request', { + props: { + eventType: 'reviewers updated', + }, + }); + const path = `api/admin/projects/${project}/change-requests/${changeRequestId}/reviewers`; + const req = createRequest(path, { + method: 'PUT', + body: JSON.stringify({ reviewers }), + }); + return makeRequest(req.caller, req.id); + }; return { addChange, @@ -200,6 +217,7 @@ export const useChangeRequestApi = () => { discardDraft, addComment, updateTitle, + updateRequestedReviewers, errors, loading, }; diff --git a/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts b/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts new file mode 100644 index 0000000000..00c83131d0 --- /dev/null +++ b/frontend/src/hooks/api/getters/useAvailableChangeRequestReviewers/useAvailableChangeRequestReviewers.ts @@ -0,0 +1,48 @@ +import useSWR from 'swr'; +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler.js'; + +// TODO: These will likely be created by Orval next time it is run +export interface AvailableReviewerSchema { + id: string; + name?: string; + email: string; + username?: string; + imageUrl?: string; +} + +export interface IAvailableReviewersResponse { + reviewers: AvailableReviewerSchema[]; + refetchReviewers: () => void; + loading: boolean; + error?: Error; +} + +export const useAvailableChangeRequestReviewers = ( + project: string, + environment: string, +): IAvailableReviewersResponse => { + const { data, error, mutate } = useSWR( + formatApiPath( + `api/admin/projects/${project}/change-requests/available-reviewers/${environment}`, + ), + fetcher, + ); + + return useMemo( + () => ({ + reviewers: data?.reviewers || [], + loading: !error && !data, + refetchReviewers: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Available Change Request Reviewers')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index d9d25327d6..5f7ded0b20 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -91,6 +91,7 @@ export type UiFlags = { healthToTechDebt?: boolean; improvedJsonDiff?: boolean; impactMetrics?: boolean; + changeRequestApproverEmails?: boolean; }; export interface IVersionInfo { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a13931cd03..496fc31597 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -674,7 +674,17 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-json@npm:6.0.1, @codemirror/lang-json@npm:^6.0.1": +"@codemirror/lang-json@npm:6.0.2": + version: 6.0.2 + resolution: "@codemirror/lang-json@npm:6.0.2" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@lezer/json": "npm:^1.0.0" + checksum: 10c0/4a36022226557d0571c143f907638eb2d46c0f7cf96c6d9a86dac397a789efa2b387e3dd3df94bac21e27692892443b24f8129c044c9012df66e68f5080745b0 + languageName: node + linkType: hard + +"@codemirror/lang-json@npm:^6.0.1": version: 6.0.1 resolution: "@codemirror/lang-json@npm:6.0.1" dependencies: @@ -3237,12 +3247,12 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:18.3.5": - version: 18.3.5 - resolution: "@types/react-dom@npm:18.3.5" +"@types/react-dom@npm:18.3.7": + version: 18.3.7 + resolution: "@types/react-dom@npm:18.3.7" peerDependencies: "@types/react": ^18.0.0 - checksum: 10c0/b163d35a6b32a79f5782574a7aeb12a31a647e248792bf437e6d596e2676961c394c5e3c6e91d1ce44ae90441dbaf93158efb4f051c0d61e2612f1cb04ce4faa + checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e languageName: node linkType: hard @@ -3303,13 +3313,13 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:18.3.18": - version: 18.3.18 - resolution: "@types/react@npm:18.3.18" +"@types/react@npm:18.3.23": + version: 18.3.23 + resolution: "@types/react@npm:18.3.23" dependencies: "@types/prop-types": "npm:*" csstype: "npm:^3.0.2" - checksum: 10c0/8fb2b00672072135d0858dc9db07873ea107cc238b6228aaa2a9afd1ef7a64a7074078250db38afbeb19064be8ea6af5eac32d404efdd5f45e093cc4829d87f8 + checksum: 10c0/49331800b76572eb2992a5c44801dbf8c612a5f99c8f4e4200f06c7de6f3a6e9455c661784a6c5469df96fa45622cb4a9d0982c44e6a0d5719be5f2ef1f545ed languageName: node linkType: hard @@ -10038,9 +10048,9 @@ __metadata: languageName: node linkType: hard -"tss-react@npm:4.9.15": - version: 4.9.15 - resolution: "tss-react@npm:4.9.15" +"tss-react@npm:4.9.18": + version: 4.9.18 + resolution: "tss-react@npm:4.9.18" dependencies: "@emotion/cache": "npm:*" "@emotion/serialize": "npm:*" @@ -10048,7 +10058,7 @@ __metadata: peerDependencies: "@emotion/react": ^11.4.1 "@emotion/server": ^11.4.0 - "@mui/material": ^5.0.0 || ^6.0.0 + "@mui/material": ^5.0.0 || ^6.0.0 || ^7.0.0 "@types/react": ^16.8.0 || ^17.0.2 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: @@ -10056,7 +10066,7 @@ __metadata: optional: true "@mui/material": optional: true - checksum: 10c0/f11069b19ec276f34a26f5f4a987c53f7898a16dcb344b3102c977070ce1a378ae14fa8a84330b2554564206d15c781cd7d181df89fc1c5039ce84047f6c9f33 + checksum: 10c0/81bfdfee892c1eb1bf253b2b456b9d573911d8b2668c757e2f5c56d01def00139bd780efe85fe9115dd7bb15477840e816586d6e999317456ca1e6cf7c1dccd3 languageName: node linkType: hard @@ -10342,7 +10352,7 @@ __metadata: resolution: "unleash-frontend-local@workspace:." dependencies: "@biomejs/biome": "npm:1.9.4" - "@codemirror/lang-json": "npm:6.0.1" + "@codemirror/lang-json": "npm:6.0.2" "@emotion/react": "npm:11.11.4" "@emotion/styled": "npm:11.11.5" "@mui/icons-material": "npm:5.15.3" @@ -10364,8 +10374,8 @@ __metadata: "@types/lodash.mapvalues": "npm:^4.6.9" "@types/lodash.omit": "npm:4.5.9" "@types/node": "npm:^22.0.0" - "@types/react": "npm:18.3.18" - "@types/react-dom": "npm:18.3.5" + "@types/react": "npm:18.3.23" + "@types/react-dom": "npm:18.3.7" "@types/react-router-dom": "npm:5.3.3" "@types/react-table": "npm:7.7.20" "@types/react-test-renderer": "npm:18.3.1" @@ -10424,7 +10434,7 @@ __metadata: sass: "npm:1.85.1" semver: "npm:7.7.2" swr: "npm:2.3.3" - tss-react: "npm:4.9.15" + tss-react: "npm:4.9.18" typescript: "npm:5.8.3" unleash-proxy-client: "npm:^3.7.3" use-query-params: "npm:^2.2.1" @@ -10439,12 +10449,12 @@ __metadata: linkType: soft "unleash-proxy-client@npm:^3.7.3": - version: 3.7.3 - resolution: "unleash-proxy-client@npm:3.7.3" + version: 3.7.6 + resolution: "unleash-proxy-client@npm:3.7.6" dependencies: tiny-emitter: "npm:^2.1.0" uuid: "npm:^9.0.1" - checksum: 10c0/3a061d4e3587325046fea0133fe405fef143dbcfdd6ed20c54200b46a22bf49acdccb6dcc0b250400a9ace2350b0065f856731a5712598d27c1e9266a141f559 + checksum: 10c0/ad365f6cbf4792506a47168f998e2d8af58db631af07a5a431d414a8d8dff49563e7caf3e0d8dbc77e9a92a6e848db412fb3c5a116fcac8870102fb4ee767594 languageName: node linkType: hard @@ -10991,8 +11001,8 @@ __metadata: linkType: hard "ws@npm:^8.18.0": - version: 8.18.1 - resolution: "ws@npm:8.18.1" + version: 8.18.2 + resolution: "ws@npm:8.18.2" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -11001,7 +11011,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/e498965d6938c63058c4310ffb6967f07d4fa06789d3364829028af380d299fe05762961742971c764973dce3d1f6a2633fe8b2d9410c9b52e534b4b882a99fa + checksum: 10c0/4b50f67931b8c6943c893f59c524f0e4905bbd183016cfb0f2b8653aa7f28dad4e456b9d99d285bbb67cca4fedd9ce90dfdfaa82b898a11414ebd66ee99141e4 languageName: node linkType: hard diff --git a/package.json b/package.json index c9798fc86a..198d041e49 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "stoppable": "^1.1.0", "tldts": "7.0.6", "ts-toolbelt": "^9.6.0", - "type-is": "^1.6.18", + "type-is": "^2.0.0", "ulidx": "^2.4.1", "unleash-client": "^6.7.0-beta.0", "uuid": "^9.0.0" @@ -177,10 +177,10 @@ "openapi-enforcer": "1.23.0", "proxyquire": "2.1.3", "source-map-support": "0.5.21", - "superagent": "10.2.0", + "superagent": "10.2.1", "supertest": "7.0.0", "ts-node": "10.9.2", - "tsc-watch": "6.2.1", + "tsc-watch": "7.1.1", "typescript": "5.8.3", "vite-node": "^3.1.3", "vitest": "^3.1.3", diff --git a/src/lib/addons/slack-app-definition.ts b/src/lib/addons/slack-app-definition.ts index 932e90f787..c7fef44fbc 100644 --- a/src/lib/addons/slack-app-definition.ts +++ b/src/lib/addons/slack-app-definition.ts @@ -62,16 +62,16 @@ import type { IAddonDefinition } from '../types/model.js'; const slackAppDefinition: IAddonDefinition = { name: 'slack-app', - displayName: 'Slack App', + displayName: 'App for Slack', description: - 'The Unleash Slack App posts messages to the selected channels in your Slack workspace.', - howTo: 'Below you can specify which Slack channels receive event notifications. The configuration settings allow you to choose the events and whether you want to filter them by projects and environments.\n\nYou can also select which channels to post to by configuring your feature flags with “slack” tags. For example, if you’d like the bot to post messages to the #general channel, you can configure your feature flag with the “slack:general” tag.\n\nThe Unleash Slack App bot has access to public channels by default. If you want the bot to post messages to private channels, you’ll need to invite it to those channels.', + 'The Unleash App for Slack posts messages to the selected channels in your Slack workspace.', + howTo: 'Below you can specify which Slack channels receive event notifications. The configuration settings allow you to choose the events and whether you want to filter them by projects and environments.\n\nYou can also select which channels to post to by configuring your feature flags with “slack” tags. For example, if you’d like the bot to post messages to the #general channel, you can configure your feature flag with the “slack:general” tag.\n\nThe Unleash App for Slack bot has access to public channels by default. If you want the bot to post messages to private channels, you’ll need to invite it to those channels.', documentationUrl: 'https://docs.getunleash.io/docs/addons/slack-app', installation: { - url: 'https://slack-app.getunleash.io/install', - title: 'Slack App installation', + url: 'https://app-for-slack.getunleash.io/install', + title: 'App for Slack installation', helpText: - 'After installing the Unleash Slack app in your Slack workspace, paste the access token into the appropriate field below in order to configure this integration.', + 'After installing the Unleash App for Slack in your Slack workspace, paste the access token into the appropriate field below in order to configure this integration.', }, parameters: [ { diff --git a/src/lib/addons/slack-definition.ts b/src/lib/addons/slack-definition.ts index 4e2ad99678..e2f69746bb 100644 --- a/src/lib/addons/slack-definition.ts +++ b/src/lib/addons/slack-definition.ts @@ -23,11 +23,11 @@ const slackDefinition: IAddonDefinition = { description: 'Allows Unleash to post updates to Slack.', documentationUrl: 'https://docs.getunleash.io/docs/addons/slack', deprecated: - 'This integration is deprecated. Please try the new Slack App integration instead.', + 'This integration is deprecated. Please try the new App for Slack integration instead.', alerts: [ { type: 'warning', - text: `This integration is deprecated. Please try the new Slack App integration instead.`, + text: `This integration is deprecated. Please try the new App for Slack integration instead.`, }, ], parameters: [ diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts index 402a8aac0c..8bd71d40f9 100644 --- a/src/lib/db/event-store.ts +++ b/src/lib/db/event-store.ts @@ -1,4 +1,4 @@ -import EventStore from '../features/events/event-store.js'; +import { EventStore } from '../features/events/event-store.js'; // For backward compatibility export * from '../features/events/event-store.js'; export default EventStore; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 696731e28c..1b5344d762 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -7,7 +7,7 @@ import { ReleasePlanTemplateStore, } from '../types/index.js'; -import EventStore from '../features/events/event-store.js'; +import { EventStore } from '../features/events/event-store.js'; import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store.js'; import FeatureTypeStore from './feature-type-store.js'; import StrategyStore from './strategy-store.js'; diff --git a/src/lib/events/index.ts b/src/lib/events/index.ts index 88901fb9d4..5f1c95dfbf 100644 --- a/src/lib/events/index.ts +++ b/src/lib/events/index.ts @@ -155,6 +155,8 @@ export const CHANGE_REQUEST_SCHEDULED_APPLICATION_FAILURE = 'change-request-scheduled-application-failure' as const; export const CHANGE_REQUEST_CONFIGURATION_UPDATED = 'change-request-configuration-updated' as const; +export const CHANGE_REQUEST_REQUESTED_APPROVERS_UPDATED = + 'change-request-requested-approvers-updated' as const; export const API_TOKEN_CREATED = 'api-token-created' as const; export const API_TOKEN_UPDATED = 'api-token-updated' as const; @@ -372,6 +374,7 @@ export const IEventTypes = [ SCIM_USERS_DELETED, SCIM_GROUPS_DELETED, CDN_TOKEN_CREATED, + CHANGE_REQUEST_REQUESTED_APPROVERS_UPDATED, ] as const; export type IEventType = (typeof IEventTypes)[number]; diff --git a/src/lib/features/client-feature-toggles/delta/createClientFeatureToggleDelta.ts b/src/lib/features/client-feature-toggles/delta/createClientFeatureToggleDelta.ts index b2793f15bc..be00ea35ec 100644 --- a/src/lib/features/client-feature-toggles/delta/createClientFeatureToggleDelta.ts +++ b/src/lib/features/client-feature-toggles/delta/createClientFeatureToggleDelta.ts @@ -1,5 +1,5 @@ import { ClientFeatureToggleDelta } from './client-feature-toggle-delta.js'; -import EventStore from '../../events/event-store.js'; +import { EventStore } from '../../events/event-store.js'; import ConfigurationRevisionService from '../../feature-toggle/configuration-revision-service.js'; import type { IUnleashConfig } from '../../../types/index.js'; import type { Db } from '../../../db/db.js'; diff --git a/src/lib/features/events/createEventsService.ts b/src/lib/features/events/createEventsService.ts index 4f01550c5d..21faea87aa 100644 --- a/src/lib/features/events/createEventsService.ts +++ b/src/lib/features/events/createEventsService.ts @@ -1,7 +1,7 @@ import FakeEventStore from '../../../test/fixtures/fake-event-store.js'; import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store.js'; import type { Db } from '../../db/db.js'; -import EventStore from './event-store.js'; +import { EventStore } from './event-store.js'; import FeatureTagStore from '../../db/feature-tag-store.js'; import { EventService } from '../../services/index.js'; import type { diff --git a/src/lib/features/events/event-created-by-migration.test.ts b/src/lib/features/events/event-created-by-migration.test.ts index f3fd35ba25..13b5434249 100644 --- a/src/lib/features/events/event-created-by-migration.test.ts +++ b/src/lib/features/events/event-created-by-migration.test.ts @@ -1,4 +1,4 @@ -import EventStore from './event-store.js'; +import { EventStore } from './event-store.js'; import getLogger from '../../../test/fixtures/no-logger.js'; import dbInit, { type ITestDb, diff --git a/src/lib/features/events/event-store.test.ts b/src/lib/features/events/event-store.test.ts index 3933c5ed0d..909cd27ae9 100644 --- a/src/lib/features/events/event-store.test.ts +++ b/src/lib/features/events/event-store.test.ts @@ -1,5 +1,5 @@ import knex from 'knex'; -import EventStore from './event-store.js'; +import { EventStore } from './event-store.js'; import getLogger from '../../../test/fixtures/no-logger.js'; import { subHours, formatRFC3339 } from 'date-fns'; import dbInit from '../../../test/e2e/helpers/database-init.js'; diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index d06fe038bc..0b08abf52e 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -101,7 +101,7 @@ export interface IEventTable { const TABLE = 'events'; -class EventStore implements IEventStore { +export class EventStore implements IEventStore { private db: Db; // only one shared event emitter should exist across all event store instances @@ -375,7 +375,7 @@ class EventStore implements IEventStore { ): Promise { const query = this.buildSearchQuery(queryParams, params.query) .select(options?.withIp ? [...EVENT_COLUMNS, 'ip'] : EVENT_COLUMNS) - .orderBy('created_at', 'desc') + .orderBy('created_at', params.order || 'desc') .limit(Number(params.limit) ?? 100) .offset(Number(params.offset) ?? 0); diff --git a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts index 124095d60d..97b3713f03 100644 --- a/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts +++ b/src/lib/features/feature-lifecycle/createFeatureLifecycle.ts @@ -3,7 +3,7 @@ import { FakeFeatureLifecycleStore } from './fake-feature-lifecycle-store.js'; import { FeatureLifecycleService } from './feature-lifecycle-service.js'; import FakeEnvironmentStore from '../project-environments/fake-environment-store.js'; import type { IUnleashConfig } from '../../types/index.js'; -import EventStore from '../../db/event-store.js'; +import { EventStore } from '../../db/event-store.js'; import type { Db } from '../../db/db.js'; import { FeatureLifecycleStore } from './feature-lifecycle-store.js'; import EnvironmentStore from '../project-environments/environment-store.js'; diff --git a/src/lib/features/feature-links/feature-link-service.test.ts b/src/lib/features/feature-links/feature-link-service.test.ts index c7d1728299..277cfe992d 100644 --- a/src/lib/features/feature-links/feature-link-service.test.ts +++ b/src/lib/features/feature-links/feature-link-service.test.ts @@ -6,13 +6,21 @@ import { NotFoundError, OperationDeniedError, } from '../../error/index.js'; +import { fakeImpactMetricsResolver } from '../../../test/fixtures/fake-impact-metrics.js'; test('create, update and delete feature link', async () => { + const flagResolver = { impactMetrics: fakeImpactMetricsResolver() }; const { featureLinkStore, featureLinkService } = createFakeFeatureLinkService({ getLogger, + flagResolver, } as unknown as IUnleashConfig); + flagResolver.impactMetrics.defineCounter( + 'feature_link_count', + 'Count of feature links', + ); + const link = await featureLinkService.createLink( 'default', { @@ -29,6 +37,10 @@ test('create, update and delete feature link', async () => { domain: 'example', }); + expect( + flagResolver.impactMetrics.counters.get('feature_link_count')!.value, + ).toBe(1); + const newLink = await featureLinkService.updateLink( { projectId: 'default', linkId: link.id }, { @@ -53,8 +65,10 @@ test('create, update and delete feature link', async () => { }); test('cannot delete/update non existent link', async () => { + const flagResolver = { impactMetrics: fakeImpactMetricsResolver() }; const { featureLinkService } = createFakeFeatureLinkService({ getLogger, + flagResolver, } as unknown as IUnleashConfig); await expect( @@ -77,8 +91,10 @@ test('cannot delete/update non existent link', async () => { }); test('cannot create/update invalid link', async () => { + const flagResolver = { impactMetrics: fakeImpactMetricsResolver() }; const { featureLinkService } = createFakeFeatureLinkService({ getLogger, + flagResolver, } as unknown as IUnleashConfig); await expect( @@ -107,8 +123,10 @@ test('cannot create/update invalid link', async () => { }); test('cannot exceed allowed link count', async () => { + const flagResolver = { impactMetrics: fakeImpactMetricsResolver() }; const { featureLinkService } = createFakeFeatureLinkService({ getLogger, + flagResolver, } as unknown as IUnleashConfig); for (let i = 0; i < 10; i++) { diff --git a/src/lib/features/feature-links/feature-link-service.ts b/src/lib/features/feature-links/feature-link-service.ts index ad9d1715b6..3be6043eb6 100644 --- a/src/lib/features/feature-links/feature-link-service.ts +++ b/src/lib/features/feature-links/feature-link-service.ts @@ -4,6 +4,7 @@ import { FeatureLinkRemovedEvent, FeatureLinkUpdatedEvent, type IAuditUser, + type IFlagResolver, type IUnleashConfig, } from '../../types/index.js'; import type { @@ -18,6 +19,7 @@ import { } from '../../error/index.js'; import normalizeUrl from 'normalize-url'; import { parse } from 'tldts'; +import { FEAUTRE_LINK_COUNT } from '../metrics/impact/define-impact-metrics.js'; interface IFeatureLinkStoreObj { featureLinkStore: IFeatureLinkStore; @@ -27,15 +29,20 @@ export default class FeatureLinkService { private logger: Logger; private featureLinkStore: IFeatureLinkStore; private eventService: EventService; + private flagResolver: IFlagResolver; constructor( stores: IFeatureLinkStoreObj, - { getLogger }: Pick, + { + getLogger, + flagResolver, + }: Pick, eventService: EventService, ) { this.logger = getLogger('feature-links/feature-link-service.ts'); this.featureLinkStore = stores.featureLinkStore; this.eventService = eventService; + this.flagResolver = flagResolver; } async getAll(): Promise { @@ -72,6 +79,8 @@ export default class FeatureLinkService { domain: domainWithoutSuffix, }); + this.flagResolver.impactMetrics?.incrementCounter(FEAUTRE_LINK_COUNT); + await this.eventService.storeEvent( new FeatureLinkAddedEvent({ featureName: newLink.featureName, diff --git a/src/lib/features/feature-links/feature-link.e2e.test.ts b/src/lib/features/feature-links/feature-link.e2e.test.ts index 83a1a3c277..2a11b58c75 100644 --- a/src/lib/features/feature-links/feature-link.e2e.test.ts +++ b/src/lib/features/feature-links/feature-link.e2e.test.ts @@ -19,17 +19,7 @@ let featureLinkReadModel: IFeatureLinksReadModel; beforeAll(async () => { db = await dbInit('feature_link', getLogger); - app = await setupAppWithAuth( - db.stores, - { - experimental: { - flags: { - featureLinks: true, - }, - }, - }, - db.rawDatabase, - ); + app = await setupAppWithAuth(db.stores, {}, db.rawDatabase); eventStore = db.stores.eventStore; featureLinkStore = db.stores.featureLinkStore; featureLinkReadModel = new FeatureLinksReadModel( diff --git a/src/lib/features/instance-stats/createInstanceStatsService.ts b/src/lib/features/instance-stats/createInstanceStatsService.ts index d4ebbbf1fb..79d5776d37 100644 --- a/src/lib/features/instance-stats/createInstanceStatsService.ts +++ b/src/lib/features/instance-stats/createInstanceStatsService.ts @@ -20,7 +20,7 @@ import SegmentStore from '../segment/segment-store.js'; import RoleStore from '../../db/role-store.js'; import SettingStore from '../../db/setting-store.js'; import ClientInstanceStore from '../../db/client-instance-store.js'; -import EventStore from '../events/event-store.js'; +import { EventStore } from '../events/event-store.js'; import { ApiTokenStore } from '../../db/api-token-store.js'; import { ClientMetricsStoreV2 } from '../metrics/client-metrics/client-metrics-store-v2.js'; import VersionService from '../../services/version-service.js'; diff --git a/src/lib/features/metrics/impact/define-impact-metrics.ts b/src/lib/features/metrics/impact/define-impact-metrics.ts new file mode 100644 index 0000000000..bd83285843 --- /dev/null +++ b/src/lib/features/metrics/impact/define-impact-metrics.ts @@ -0,0 +1,25 @@ +import type { IFlagResolver } from '../../../types/index.js'; + +export const FEAUTRE_LINK_COUNT = 'feature_link_count'; +export const CLIENT_ERROR_COUNT = 'client_error_count'; +export const SERVER_ERROR_COUNT = 'server_error_count'; +export const HEAP_MEMORY_TOTAL = 'heap_memory_total'; + +export const defineImpactMetrics = (flagResolver: IFlagResolver) => { + flagResolver.impactMetrics?.defineCounter( + FEAUTRE_LINK_COUNT, + 'Count of feature links', + ); + flagResolver.impactMetrics?.defineCounter( + CLIENT_ERROR_COUNT, + 'Count of 4xx errors', + ); + flagResolver.impactMetrics?.defineCounter( + SERVER_ERROR_COUNT, + 'Count of 5xx errors', + ); + flagResolver.impactMetrics?.defineGauge( + HEAP_MEMORY_TOTAL, + 'Total heap memory used by the application process', + ); +}; diff --git a/src/lib/features/personal-dashboard/createPersonalDashboardService.ts b/src/lib/features/personal-dashboard/createPersonalDashboardService.ts index 10deaeadff..c446ebc254 100644 --- a/src/lib/features/personal-dashboard/createPersonalDashboardService.ts +++ b/src/lib/features/personal-dashboard/createPersonalDashboardService.ts @@ -7,7 +7,7 @@ import { ProjectOwnersReadModel } from '../project/project-owners-read-model.js' import { FakeProjectOwnersReadModel } from '../project/fake-project-owners-read-model.js'; import { ProjectReadModel } from '../project/project-read-model.js'; import { FakeProjectReadModel } from '../project/fake-project-read-model.js'; -import EventStore from '../../db/event-store.js'; +import { EventStore } from '../../db/event-store.js'; import { FeatureEventFormatterMd } from '../../addons/feature-event-formatter-md.js'; import FakeEventStore from '../../../test/fixtures/fake-event-store.js'; import { FakePrivateProjectChecker } from '../private-project/fakePrivateProjectChecker.js'; diff --git a/src/lib/features/project-status/createProjectStatusService.ts b/src/lib/features/project-status/createProjectStatusService.ts index 2981bbe834..6d9b2a8ba4 100644 --- a/src/lib/features/project-status/createProjectStatusService.ts +++ b/src/lib/features/project-status/createProjectStatusService.ts @@ -1,6 +1,6 @@ import type { Db, IUnleashConfig } from '../../types/index.js'; import { ProjectStatusService } from './project-status-service.js'; -import EventStore from '../events/event-store.js'; +import { EventStore } from '../events/event-store.js'; import FakeEventStore from '../../../test/fixtures/fake-event-store.js'; import ProjectStore from '../project/project-store.js'; import FakeProjectStore from '../../../test/fixtures/fake-project-store.js'; diff --git a/src/lib/features/project/createProjectService.ts b/src/lib/features/project/createProjectService.ts index cf124e6491..03051e44b6 100644 --- a/src/lib/features/project/createProjectService.ts +++ b/src/lib/features/project/createProjectService.ts @@ -1,5 +1,5 @@ import type { Db, IUnleashConfig } from '../../types/index.js'; -import EventStore from '../events/event-store.js'; +import { EventStore } from '../events/event-store.js'; import GroupStore from '../../db/group-store.js'; import { AccountStore } from '../../db/account-store.js'; import EnvironmentStore from '../project-environments/environment-store.js'; diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index a483c7313c..0cb7a47ee4 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -41,6 +41,7 @@ import { import type { SchedulerService } from './services/index.js'; import type { IClientMetricsEnv } from './features/metrics/client-metrics/client-metrics-store-v2-type.js'; import { DbMetricsMonitor } from './metrics-gauge.js'; +import { HEAP_MEMORY_TOTAL } from './features/metrics/impact/define-impact-metrics.js'; export function registerPrometheusPostgresMetrics( db: Knex, @@ -1137,6 +1138,10 @@ export function registerPrometheusMetrics( collectAggDbMetrics: dbMetrics.refreshMetrics, collectStaticCounters: async () => { try { + config.flagResolver.impactMetrics?.updateGauge( + HEAP_MEMORY_TOTAL, + process.memoryUsage().heapUsed, + ); featureTogglesArchivedTotal.reset(); featureTogglesArchivedTotal.set( await instanceStatsService.getArchivedToggleCount(), diff --git a/src/lib/middleware/request-logger.ts b/src/lib/middleware/request-logger.ts index c0ec411f63..91468b3ed9 100644 --- a/src/lib/middleware/request-logger.ts +++ b/src/lib/middleware/request-logger.ts @@ -1,14 +1,25 @@ import url from 'url'; import type { RequestHandler } from 'express'; import type { IUnleashConfig } from '../types/option.js'; +import { + CLIENT_ERROR_COUNT, + SERVER_ERROR_COUNT, +} from '../features/metrics/impact/define-impact-metrics.js'; const requestLogger: (config: IUnleashConfig) => RequestHandler = (config) => { const logger = config.getLogger('HTTP'); const enable = config.server.enableRequestLogger; + const impactMetrics = config.flagResolver.impactMetrics; return (req, res, next) => { if (enable) { res.on('finish', () => { const { pathname } = url.parse(req.originalUrl); + if (res.statusCode >= 400 && res.statusCode < 500) { + impactMetrics?.incrementCounter(CLIENT_ERROR_COUNT); + } + if (res.statusCode >= 500) { + impactMetrics?.incrementCounter(SERVER_ERROR_COUNT); + } logger.info(`${res.statusCode} ${req.method} ${pathname}`); }); } diff --git a/src/lib/openapi/spec/addon-type-schema.ts b/src/lib/openapi/spec/addon-type-schema.ts index 747c7cedc8..856ad7fc93 100644 --- a/src/lib/openapi/spec/addon-type-schema.ts +++ b/src/lib/openapi/spec/addon-type-schema.ts @@ -136,20 +136,20 @@ export const addonTypeSchema = { type: 'string', description: 'A URL to where the addon configuration should redirect to install addons of this type.', - example: 'https://slack-app.getunleash.io/install', + example: 'https://app-for-slack.getunleash.io/install', }, title: { type: 'string', description: 'The title of the installation configuration. This will be displayed to the user when installing addons of this type.', - example: 'Slack App installation', + example: 'App for Slack installation', }, helpText: { type: 'string', description: 'The help text of the installation configuration. This will be displayed to the user when installing addons of this type.', example: - 'Clicking the Install button will send you to Slack to initiate the installation procedure for the Unleash Slack app for your workspace', + 'Clicking the Install button will send you to Slack to initiate the installation procedure for the Unleash App for Slack for your workspace', }, }, }, @@ -174,7 +174,7 @@ export const addonTypeSchema = { description: 'The text of the alert. This is what will be displayed to the user.', example: - "Please ensure you have the Unleash Slack App installed in your Slack workspace if you haven't installed it already. If you want the Unleash Slack App bot to post messages to private channels, you'll need to invite it to those channels.", + "Please ensure you have the Unleash App for Slack installed in your Slack workspace if you haven't installed it already. If you want the Unleash App for Slack bot to post messages to private channels, you'll need to invite it to those channels.", }, }, }, diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts index 21e2cef94a..a86a005ff7 100644 --- a/src/lib/server-impl.ts +++ b/src/lib/server-impl.ts @@ -126,7 +126,7 @@ import metricsHelper from './util/metrics-helper.js'; import type { ReleasePlanMilestoneWriteModel } from './features/release-plans/release-plan-milestone-store.js'; import type { ReleasePlanMilestoneStrategyWriteModel } from './features/release-plans/release-plan-milestone-strategy-store.js'; import type { IChangeRequestAccessReadModel } from './features/change-request-access-service/change-request-access-read-model.js'; -import EventStore from './db/event-store.js'; +import { EventStore } from './db/event-store.js'; import RoleStore from './db/role-store.js'; import { AccessStore } from './db/access-store.js'; import { @@ -183,6 +183,7 @@ import { testDbPrefix } from '../test/e2e/helpers/database-init.js'; import type { RequestHandler } from 'express'; import { UPDATE_REVISION } from './features/feature-toggle/configuration-revision-service.js'; import type { IFeatureUsageInfo } from './services/version-service.js'; +import { defineImpactMetrics } from './features/metrics/impact/define-impact-metrics.js'; export async function initialServiceSetup( { authentication }: Pick, @@ -232,6 +233,7 @@ export async function createApp( scheduleServices(services, config); } + defineImpactMetrics(config.flagResolver); const metricsMonitor = fm.createMetricsMonitor(); const unleashSession = fm.createSessionDb(config, db); diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 581cd03316..41331db1c1 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -15,6 +15,7 @@ export interface IEventSearchParams { createdBy?: string; type?: string; environment?: string; + order?: 'asc' | 'desc'; // desc by default offset: number; limit: number; } diff --git a/src/lib/util/flag-resolver.ts b/src/lib/util/flag-resolver.ts index 0551e8cedc..453e6ea84a 100644 --- a/src/lib/util/flag-resolver.ts +++ b/src/lib/util/flag-resolver.ts @@ -6,6 +6,7 @@ import type { IFlags, IFlagResolver, IFlagKey, + IImpactMetricsResolver, } from '../types/experimental.js'; import { getDefaultVariant } from 'unleash-client/lib/variant.js'; @@ -65,6 +66,10 @@ export default class FlagResolver implements IFlagResolver { getStaticContext(): IFlagContext { return this.externalResolver.getStaticContext(); } + + get impactMetrics(): IImpactMetricsResolver | undefined { + return this.externalResolver?.impactMetrics; + } } export const getVariantValue = ( diff --git a/src/test/fixtures/fake-impact-metrics.ts b/src/test/fixtures/fake-impact-metrics.ts new file mode 100644 index 0000000000..440e79554d --- /dev/null +++ b/src/test/fixtures/fake-impact-metrics.ts @@ -0,0 +1,34 @@ +export const fakeImpactMetricsResolver = () => ({ + counters: new Map(), + gauges: new Map(), + + defineCounter(name: string, help: string) { + this.counters.set(name, { value: 0, help }); + }, + + defineGauge(name: string, help: string) { + this.gauges.set(name, { value: 0, help }); + }, + + incrementCounter(name: string, value: number = 1) { + const counter = this.counters.get(name); + + if (!counter) { + return; + } + + counter.value += value; + this.counters.set(name, counter); + }, + + updateGauge(name: string, value: number) { + const gauge = this.gauges.get(name); + + if (!gauge) { + return; + } + + gauge.value = value; + this.gauges.set(name, gauge); + }, +}); diff --git a/website/docs/reference/insights.mdx b/website/docs/reference/insights.mdx index ce9d38c060..10dda53bef 100644 --- a/website/docs/reference/insights.mdx +++ b/website/docs/reference/insights.mdx @@ -1,5 +1,5 @@ --- -title: Insights +title: Analytics --- :::note Availability @@ -9,16 +9,16 @@ title: Insights ::: -Insights is a feature designed to help you better understand and gain insights into what is happening in your Unleash instance. You can view insights across all projects or by selecting single or multiple projects using the filter. +Analytics is a feature designed to help you better understand and gain insights into what is happening in your Unleash instance. You can view analytics across all projects or by selecting single or multiple projects using the filter. In total, there are 6 different charts available that show information over time: -- Total users (Pro, Enterprise) -- Flags (Pro, Enterprise) -- Health (Enterprise) -- Median time to production (Enterprise) -- Flag evaluation metrics (Enterprise) -- Updates per environment type (Enterprise) +- Total users +- Flags +- Technical debt +- Median time to production +- Flag evaluation metrics +- Updates per environment type ### Total users @@ -33,11 +33,11 @@ The flags chart displays the total number of active (not archived) feature flags ![Flags chart](/img/insights-total-flags.png) -### Health +### Technical debt -The health chart represents the percentage of flags in the selected projects that are not stale or potentially stale. This chart helps you monitor the overall health of your feature flags, ensuring that they are actively maintained and relevant. The chart also shows how the overall health changes over time, allowing you to identify potential issues early and take corrective actions. +The technical debt rating shows the percentage of healthy flags in a project compared to stale or potentially stale flags. This helps you ensure all flags are actively maintained and relevant. You can also view these changes over time to identify potential issues early and take corrective actions. -![Health chart](/img/insights-health.png) +![Technical debt chart](/img/insights-technical-debt.png) ### Median time to production diff --git a/website/docs/reference/integrations/integrations.mdx b/website/docs/reference/integrations/integrations.mdx index ae38ede817..3184b1ffdb 100644 --- a/website/docs/reference/integrations/integrations.mdx +++ b/website/docs/reference/integrations/integrations.mdx @@ -22,7 +22,7 @@ Unleash currently supports the following integrations out of the box: - [Jira Cloud](./integrations/jira-cloud-plugin-usage) - Allows you to create, view and manage Unleash feature flags directly from a Jira Cloud issue - [Jira Server](./integrations/jira-server-plugin-usage) - Allows you to create and link Unleash feature flags directly from a Jira Server issue - [Microsoft Teams](./integrations/teams) - Allows Unleash to post updates to Microsoft Teams. -- [Slack App](./integrations/slack-app) - The Unleash Slack App posts messages to the selected channels in your Slack workspace. +- [App for Slack](./integrations/slack-app) - The Unleash App for Slack posts messages to the selected channels in your Slack workspace. - [Webhook](./integrations/webhook) - A generic way to post messages from Unleash to third party services. :::tip Missing an integration? Request it! @@ -35,7 +35,7 @@ If you're looking for an integration that Unleash doesn't have at the moment, yo These integrations are deprecated and will be removed in a future release: -- [Slack](./integrations/slack) - Allows Unleash to post updates to Slack. Please try the new [Slack App](./integrations/slack-app) integration instead. +- [Slack](./integrations/slack) - Allows Unleash to post updates to Slack. Please try the new [App for Slack](./integrations/slack-app) integration instead. ## Community integrations @@ -62,7 +62,7 @@ Integration events are logged for all outgoing integrations configured in Unleas - [Microsoft Teams](./integrations/teams) - New Relic - [Slack (deprecated)](./integrations/slack) - - [Slack App](./integrations/slack-app) + - [App for Slack](./integrations/slack-app) - [Webhook](./integrations/webhook) ### Viewing integration events diff --git a/website/docs/reference/integrations/slack-app.mdx b/website/docs/reference/integrations/slack-app.mdx index d3ae29a6df..0b63f902e4 100644 --- a/website/docs/reference/integrations/slack-app.mdx +++ b/website/docs/reference/integrations/slack-app.mdx @@ -1,5 +1,5 @@ --- -title: Slack App +title: App for Slack --- :::note Availability @@ -8,19 +8,19 @@ title: Slack App ::: -The Slack App integration posts messages to a specified set of channels in your Slack workspace. The channels can be public or private, and can be specified on a per-flag basis by using [Slack tags](#tags). +The App for Slack integration posts messages to a specified set of channels in your Slack workspace. The channels can be public or private, and can be specified on a per-flag basis by using [Slack tags](#tags). ## Installation {#installation} -To install the Slack App integration, follow these steps: +To install the App for Slack integration, follow these steps: -1. Navigate to the *integrations* page in the Unleash admin UI (available at the URL `/integrations`) and select "configure" on the Slack App integration. +1. Navigate to the *integrations* page in the Unleash admin UI (available at the URL `/integrations`) and select "configure" on the App for Slack integration. 2. On the integration configuration form, use the "install & connect" button. 3. A new tab will open, asking you to select the Slack workspace where you'd like to install the app. -4. After successful installation of the Unleash Slack App in your chosen Slack workspace, you'll be automatically redirected to a page displaying a newly generated access token. +4. After successful installation of the Unleash App for Slack in your chosen Slack workspace, you'll be automatically redirected to a page displaying a newly generated access token. 5. Copy this access token and paste it into the `Access token` field within the integration settings. -By default, the Unleash Slack App is granted access to public channels. If you want the app to post messages to private channels, you'll need to manually invite it to each of those channels. +By default, the Unleash App for Slack is granted access to public channels. If you want the app to post messages to private channels, you'll need to manually invite it to each of those channels. ## Configuration {#configuration} @@ -84,14 +84,14 @@ You can choose to trigger updates for the following events: #### Parameters {#parameters} -The Unleash Slack App integration takes the following parameters. +The Unleash App for Slack integration takes the following parameters. -- **Access token** - This is the only required property. After successful installation of the Unleash Slack App in your chosen Slack workspace, you'll be automatically redirected to a page displaying a newly generated access token. You should copy this access token and paste it into this field. +- **Access token** - This is the only required property. After successful installation of the Unleash App for Slack in your chosen Slack workspace, you'll be automatically redirected to a page displaying a newly generated access token. You should copy this access token and paste it into this field. - **Channels** - A comma-separated list of channels to post the configured events to. These channels are always notified, regardless of the event type or the presence of a Slack tag. ## Tags {#tags} -Besides the configured channels, you can choose to notify other channels by tagging your feature flags with Slack-specific tags. For instance, if you want the Unleash Slack App to send notifications to the `#general` channel, add a Slack-type tag with the value "general" (or "#general"; both will work) to your flag. This will ensure that any configured events related to that feature flag will notify the tagged channel in addition to any channels configured on the integration-level. +Besides the configured channels, you can choose to notify other channels by tagging your feature flags with Slack-specific tags. For instance, if you want the Unleash App for Slack to send notifications to the `#general` channel, add a Slack-type tag with the value "general" (or "#general"; both will work) to your flag. This will ensure that any configured events related to that feature flag will notify the tagged channel in addition to any channels configured on the integration-level. To exclusively use tags for determining notification channels, you can leave the "channels" field blank in the integration configuration. Since you can have multiple configurations for the integration, you're free to mix and match settings to meet your precise needs. Before posting a message, all channels for that event, both configured and tagged, are combined and duplicates are removed. diff --git a/website/docs/reference/integrations/slack.md b/website/docs/reference/integrations/slack.md index 124d6c808e..07cfcfab1f 100644 --- a/website/docs/reference/integrations/slack.md +++ b/website/docs/reference/integrations/slack.md @@ -5,7 +5,7 @@ title: Slack (deprecated) :::caution Deprecation notice -This Slack integration is deprecated and will be removed in a future release. We recommend using the new [Slack App](./slack-app) integration instead. +This Slack integration is deprecated and will be removed in a future release. We recommend using the new [App for Slack](./slack-app) integration instead. ::: diff --git a/website/docs/reference/projects.mdx b/website/docs/reference/projects.mdx index 84ee9156c3..794c301aee 100644 --- a/website/docs/reference/projects.mdx +++ b/website/docs/reference/projects.mdx @@ -117,10 +117,10 @@ To change the default strategy for an environment in a project: Unleash supports [predefined](./rbac#predefined-roles) and [custom roles](./rbac#custom-project-roles) at the project level. The two predefined project roles are Owner and Member. By default, the person creating the project becomes the Owner. If a project does not have an Owner, it is shown as owned by _System_. -## View project insights +## View project status -Project insights is a great way to see how your project performed in the last 30 days compared to the previous 30 days. You can explore key metrics such as the total number of changes, the average time to production, the number of features created and archived, and project health. +The [Project status](./technical-debt#project-status) dashboard provides an overview of your project's technical debt, information on project resources like API keys, recent activity within the project, and feature flag lifecycle information. -To view your project insights, go to the **Insights** within a project. +For additional, in-depth analysis, go to **Analytics** and filter by your project. diff --git a/website/docs/reference/technical-debt.md b/website/docs/reference/technical-debt.md index ba4254fb8e..f0b42b0c56 100644 --- a/website/docs/reference/technical-debt.md +++ b/website/docs/reference/technical-debt.md @@ -32,8 +32,6 @@ While a flag's state does not affect its behavior in applications, using states ## Project status -Each project has a **Project status** dashboard, where you can view its health status and the total number of unhealthy flags. All active flags are considered healthy, while stale and potentially stale flags are considered unhealthy. To keep your project in a healthy state, [archive stale feature flags](/reference/feature-toggles#archive-a-feature-flag) and remove code from your codebase. +Each project has a **Project status** dashboard, where you can view its technical debt rating—the percentage of healthy flags compared to stale or potentially stale flags. To keep your project's technical debt low, [archive stale feature flags](/reference/feature-toggles#archive-a-feature-flag) and remove them from your codebase. To view your project's technical debt rating over time, go to [Analytics](/reference/insights). ![Project status dashboard](/img/project-status-dashboard.png) - -Your overall project health rating is the percentage of healthy flags in your project. To view your project health over time, go to [Insights](/reference/insights). \ No newline at end of file diff --git a/website/static/img/insights-health.png b/website/static/img/insights-health.png deleted file mode 100644 index 17d009a38c..0000000000 Binary files a/website/static/img/insights-health.png and /dev/null differ diff --git a/website/static/img/insights-technical-debt.png b/website/static/img/insights-technical-debt.png new file mode 100644 index 0000000000..eec6096e76 Binary files /dev/null and b/website/static/img/insights-technical-debt.png differ diff --git a/website/static/img/project-status-dashboard.png b/website/static/img/project-status-dashboard.png index 1b020446e7..7f8effb626 100644 Binary files a/website/static/img/project-status-dashboard.png and b/website/static/img/project-status-dashboard.png differ diff --git a/website/yarn.lock b/website/yarn.lock index c573d153f1..4c5df5c34a 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -4171,21 +4171,21 @@ __metadata: linkType: hard "@types/react@npm:*": - version: 19.0.2 - resolution: "@types/react@npm:19.0.2" + version: 19.1.8 + resolution: "@types/react@npm:19.1.8" dependencies: csstype: "npm:^3.0.2" - checksum: 10c0/8992f39701fcf1bf893ef8f94a56196445667baf08fe4f6050a14e229a17aad3265ad3efc01595ff3b4d5d5c69da885f9aa4ff80f164a613018734efcff1eb8f + checksum: 10c0/4908772be6dc941df276931efeb0e781777fa76e4d5d12ff9f75eb2dcc2db3065e0100efde16fde562c5bafa310cc8f50c1ee40a22640459e066e72cd342143e languageName: node linkType: hard "@types/react@npm:^18.3.12": - version: 18.3.18 - resolution: "@types/react@npm:18.3.18" + version: 18.3.23 + resolution: "@types/react@npm:18.3.23" dependencies: "@types/prop-types": "npm:*" csstype: "npm:^3.0.2" - checksum: 10c0/8fb2b00672072135d0858dc9db07873ea107cc238b6228aaa2a9afd1ef7a64a7074078250db38afbeb19064be8ea6af5eac32d404efdd5f45e093cc4829d87f8 + checksum: 10c0/49331800b76572eb2992a5c44801dbf8c612a5f99c8f4e4200f06c7de6f3a6e9455c661784a6c5469df96fa45622cb4a9d0982c44e6a0d5719be5f2ef1f545ed languageName: node linkType: hard @@ -6176,7 +6176,7 @@ __metadata: languageName: node linkType: hard -"create-hash@npm:^1.1.0, create-hash@npm:^1.1.2, create-hash@npm:^1.2.0": +"create-hash@npm:^1.1.0, create-hash@npm:^1.2.0": version: 1.2.0 resolution: "create-hash@npm:1.2.0" dependencies: @@ -6189,7 +6189,19 @@ __metadata: languageName: node linkType: hard -"create-hmac@npm:^1.1.4, create-hmac@npm:^1.1.7": +"create-hash@npm:~1.1.3": + version: 1.1.3 + resolution: "create-hash@npm:1.1.3" + dependencies: + cipher-base: "npm:^1.0.1" + inherits: "npm:^2.0.1" + ripemd160: "npm:^2.0.0" + sha.js: "npm:^2.4.0" + checksum: 10c0/dbcf4a1b13c8dd5f2a69f5f30bd2701f919ed7d3fbf5aa530cf00b17a950c2b77f63bfe6a2981735a646ae2620d96c8f4584bf70aeeabf050a31de4e46219d08 + languageName: node + linkType: hard + +"create-hmac@npm:^1.1.7": version: 1.1.7 resolution: "create-hmac@npm:1.1.7" dependencies: @@ -8697,6 +8709,15 @@ __metadata: languageName: node linkType: hard +"hash-base@npm:^2.0.0": + version: 2.0.2 + resolution: "hash-base@npm:2.0.2" + dependencies: + inherits: "npm:^2.0.1" + checksum: 10c0/283f6060277b52e627a734c4d19d4315ba82326cab5a2f4f2f00b924d747dc7cc902a8cedb1904c7a3501075fcbb24c08de1152bae296698fdc5ad75b33986af + languageName: node + linkType: hard + "hash-base@npm:^3.0.0": version: 3.1.0 resolution: "hash-base@npm:3.1.0" @@ -9735,7 +9756,7 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.3": +"is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.3": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -9774,6 +9795,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:^2.0.5": + version: 2.0.5 + resolution: "isarray@npm:2.0.5" + checksum: 10c0/4199f14a7a13da2177c66c31080008b7124331956f47bca57dd0b6ea9f11687aa25e565a2c7a2b519bc86988d10398e3049a1f5df13c9f6b7664154690ae79fd + languageName: node + linkType: hard + "isarray@npm:~1.0.0": version: 1.0.0 resolution: "isarray@npm:1.0.0" @@ -12859,15 +12887,16 @@ __metadata: linkType: hard "pbkdf2@npm:^3.1.2": - version: 3.1.2 - resolution: "pbkdf2@npm:3.1.2" + version: 3.1.3 + resolution: "pbkdf2@npm:3.1.3" dependencies: - create-hash: "npm:^1.1.2" - create-hmac: "npm:^1.1.4" - ripemd160: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - sha.js: "npm:^2.4.8" - checksum: 10c0/5a30374e87d33fa080a92734d778cf172542cc7e41b96198c4c88763997b62d7850de3fbda5c3111ddf79805ee7c1da7046881c90ac4920b5e324204518b05fd + create-hash: "npm:~1.1.3" + create-hmac: "npm:^1.1.7" + ripemd160: "npm:=2.0.1" + safe-buffer: "npm:^5.2.1" + sha.js: "npm:^2.4.11" + to-buffer: "npm:^1.2.0" + checksum: 10c0/12779463dfb847701f186e0b7e5fd538a1420409a485dcf5100689c2b3ec3cb113204e82a68668faf3b6dd76ec19260b865313c9d3a9c252807163bdc24652ae languageName: node linkType: hard @@ -14941,6 +14970,16 @@ plugin-image-zoom@flexanalytics/plugin-image-zoom: languageName: node linkType: hard +"ripemd160@npm:=2.0.1": + version: 2.0.1 + resolution: "ripemd160@npm:2.0.1" + dependencies: + hash-base: "npm:^2.0.0" + inherits: "npm:^2.0.1" + checksum: 10c0/d4cbb4713c1268bb35e44815b12e3744a952a72b72e6a72110c8f3932227ddf68841110285fe2ed1c04805e2621d85f905deb5f55f9d91fa1bfc0f8081a244e6 + languageName: node + linkType: hard + "ripemd160@npm:^2.0.0, ripemd160@npm:^2.0.1": version: 2.0.2 resolution: "ripemd160@npm:2.0.2" @@ -15324,7 +15363,7 @@ plugin-image-zoom@flexanalytics/plugin-image-zoom: languageName: node linkType: hard -"sha.js@npm:^2.4.0, sha.js@npm:^2.4.8": +"sha.js@npm:^2.4.0, sha.js@npm:^2.4.11, sha.js@npm:^2.4.8": version: 2.4.11 resolution: "sha.js@npm:2.4.11" dependencies: @@ -16122,6 +16161,17 @@ plugin-image-zoom@flexanalytics/plugin-image-zoom: languageName: node linkType: hard +"to-buffer@npm:^1.2.0": + version: 1.2.1 + resolution: "to-buffer@npm:1.2.1" + dependencies: + isarray: "npm:^2.0.5" + safe-buffer: "npm:^5.2.1" + typed-array-buffer: "npm:^1.0.3" + checksum: 10c0/bbf07a2a7d6ff9e3ffe503c689176c7149cf3ec25887ce7c4aa5c4841a8845cc71121cd7b4a4769957f823b3f31dbf6b1be6e0a5955798ad864bf2245ee8b5e4 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -16232,6 +16282,17 @@ plugin-image-zoom@flexanalytics/plugin-image-zoom: languageName: node linkType: hard +"typed-array-buffer@npm:^1.0.3": + version: 1.0.3 + resolution: "typed-array-buffer@npm:1.0.3" + dependencies: + call-bound: "npm:^1.0.3" + es-errors: "npm:^1.3.0" + is-typed-array: "npm:^1.1.14" + checksum: 10c0/1105071756eb248774bc71646bfe45b682efcad93b55532c6ffa4518969fb6241354e4aa62af679ae83899ec296d69ef88f1f3763657cdb3a4d29321f7b83079 + languageName: node + linkType: hard + "typedarray-to-buffer@npm:^3.1.5": version: 3.1.5 resolution: "typedarray-to-buffer@npm:3.1.5" diff --git a/yarn.lock b/yarn.lock index 8e8fef902f..805a565976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2560,7 +2560,7 @@ __metadata: languageName: node linkType: hard -"content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af @@ -2727,6 +2727,17 @@ __metadata: languageName: node linkType: hard +"cross-spawn@npm:^7.0.6": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1 + languageName: node + linkType: hard + "d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": version: 1.0.2 resolution: "d@npm:1.0.2" @@ -3590,14 +3601,14 @@ __metadata: linkType: hard "fetch-mock@npm:^12.0.0": - version: 12.5.2 - resolution: "fetch-mock@npm:12.5.2" + version: 12.5.3 + resolution: "fetch-mock@npm:12.5.3" dependencies: "@types/glob-to-regexp": "npm:^0.4.4" dequal: "npm:^2.0.3" glob-to-regexp: "npm:^0.4.1" regexparam: "npm:^3.0.0" - checksum: 10c0/015ca2c7eba304beb0df06e15399c50ed0f3e167a2b1ea218a8505c468ddeae0cfab1e5d4efcdee2a62aa17c9c8a1d8ccb5e1683b00a55e7a93bb5b31b183688 + checksum: 10c0/820c5b66d855c4b48698d57f7b46861515eb05a0511f98f648c05573e3b891e1a693c8f5e2db603cac32167e28673799251268a78bcd178d06e6457151f92616 languageName: node linkType: hard @@ -3748,7 +3759,7 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^3.5.1, formidable@npm:^3.5.2": +"formidable@npm:^3.5.1, formidable@npm:^3.5.4": version: 3.5.4 resolution: "formidable@npm:3.5.4" dependencies: @@ -5109,6 +5120,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.0 + resolution: "media-typer@npm:1.1.0" + checksum: 10c0/7b4baa40b25964bb90e2121ee489ec38642127e48d0cc2b6baa442688d3fde6262bfdca86d6bbf6ba708784afcac168c06840c71facac70e390f5f759ac121b9 + languageName: node + linkType: hard + "memoizee@npm:^0.4.17": version: 0.4.17 resolution: "memoizee@npm:0.4.17" @@ -5195,6 +5213,13 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: 10c0/8d907917bc2a90fa2df842cdf5dfeaf509adc15fe0531e07bb2f6ab15992416479015828d6a74200041c492e42cce3ebf78e5ce714388a0a538ea9c53eece284 + languageName: node + linkType: hard + "mime-types@npm:^2.1.12, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" @@ -5204,6 +5229,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^3.0.0": + version: 3.0.1 + resolution: "mime-types@npm:3.0.1" + dependencies: + mime-db: "npm:^1.54.0" + checksum: 10c0/bd8c20d3694548089cf229016124f8f40e6a60bbb600161ae13e45f793a2d5bb40f96bbc61f275836696179c77c1d6bf4967b2a75e0a8ad40fe31f4ed5be4da5 + languageName: node + linkType: hard + "mime@npm:*, mime@npm:^4.0.4": version: 4.0.6 resolution: "mime@npm:4.0.6" @@ -7102,13 +7136,6 @@ __metadata: languageName: node linkType: hard -"string-argv@npm:^0.3.1": - version: 0.3.1 - resolution: "string-argv@npm:0.3.1" - checksum: 10c0/f59582070f0a4a2d362d8331031f313771ad2b939b223b0593d7765de2689c975e0069186cef65977a29af9deec248c7e480ea4015d153ead754aea5e4bcfe7c - languageName: node - linkType: hard - "string-argv@npm:^0.3.2": version: 0.3.2 resolution: "string-argv@npm:0.3.2" @@ -7206,20 +7233,20 @@ __metadata: languageName: node linkType: hard -"superagent@npm:10.2.0": - version: 10.2.0 - resolution: "superagent@npm:10.2.0" +"superagent@npm:10.2.1": + version: 10.2.1 + resolution: "superagent@npm:10.2.1" dependencies: component-emitter: "npm:^1.3.0" cookiejar: "npm:^2.1.4" debug: "npm:^4.3.4" fast-safe-stringify: "npm:^2.1.1" form-data: "npm:^4.0.0" - formidable: "npm:^3.5.2" + formidable: "npm:^3.5.4" methods: "npm:^1.1.2" mime: "npm:2.6.0" qs: "npm:^6.11.0" - checksum: 10c0/a1616a352831feddbcb7fa04c0af0a65d1ac68f03c5d7710d4df25c71cd470721764f9a180aac8605c6695f2e8fee23a037457169b23467045b5d43bc8cbc646 + checksum: 10c0/526e3716f765873fc2f98a00fe0c8cbfe57976c501a30486307eefe54a6a5e379a0adf2ca8b29d40bc33a34d6baf7814972b0398b6002445ca3b4af07487f090 languageName: node linkType: hard @@ -7534,19 +7561,19 @@ __metadata: languageName: node linkType: hard -"tsc-watch@npm:6.2.1": - version: 6.2.1 - resolution: "tsc-watch@npm:6.2.1" +"tsc-watch@npm:7.1.1": + version: 7.1.1 + resolution: "tsc-watch@npm:7.1.1" dependencies: - cross-spawn: "npm:^7.0.3" + cross-spawn: "npm:^7.0.6" node-cleanup: "npm:^2.1.2" ps-tree: "npm:^1.2.0" - string-argv: "npm:^0.3.1" + string-argv: "npm:^0.3.2" peerDependencies: typescript: "*" bin: tsc-watch: dist/lib/tsc-watch.js - checksum: 10c0/f5fe19e5ac9f4c42a5600c20aee9ff49e282f11813aead65ed58fa11d98a20f5a82bf4f931897270f49f6475dd54e9aab9c46a07c3801b8d237dfbe77bcf1bfc + checksum: 10c0/e69b530c2664213574aa67bb47544cf8d4e55ea46cd2a8929f44d8b8c8a70c3574b9ebe7b1752348690b276ece895f8db86fcf84c1d88be9868eb0705cce2dad languageName: node linkType: hard @@ -7591,7 +7618,18 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^1.6.18, type-is@npm:~1.6.18": +"type-is@npm:^2.0.0": + version: 2.0.1 + resolution: "type-is@npm:2.0.1" + dependencies: + content-type: "npm:^1.0.5" + media-typer: "npm:^1.1.0" + mime-types: "npm:^3.0.0" + checksum: 10c0/7f7ec0a060b16880bdad36824ab37c26019454b67d73e8a465ed5a3587440fbe158bc765f0da68344498235c877e7dbbb1600beccc94628ed05599d667951b99 + languageName: node + linkType: hard + +"type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" dependencies: @@ -7827,13 +7865,13 @@ __metadata: slug: "npm:^9.0.0" source-map-support: "npm:0.5.21" stoppable: "npm:^1.1.0" - superagent: "npm:10.2.0" + superagent: "npm:10.2.1" supertest: "npm:7.0.0" tldts: "npm:7.0.6" ts-node: "npm:10.9.2" ts-toolbelt: "npm:^9.6.0" - tsc-watch: "npm:6.2.1" - type-is: "npm:^1.6.18" + tsc-watch: "npm:7.1.1" + type-is: "npm:^2.0.0" typescript: "npm:5.8.3" ulidx: "npm:^2.4.1" unleash-client: "npm:^6.7.0-beta.0"