1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

Merge branch 'main' into feat/impact-metrics-frontend

This commit is contained in:
Tymoteusz Czech 2025-06-27 09:48:39 +02:00 committed by GitHub
commit 071c869433
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 738 additions and 176 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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": {

View File

@ -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 }) => (
<Button
sx={{ ml: 2 }}
variant='contained'
onClick={onClick}
disabled={disabled}
>
Submit change request ({count})
</Button>
);
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<HTMLLIElement>,
option: AvailableReviewerSchema,
{ selected }: { selected: boolean },
) => (
<StrechedLi {...props} key={option.id}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
checkedIcon={<CheckBoxIcon fontSize='small' />}
style={{ marginRight: 8 }}
checked={selected}
/>
<StyledOption>
<span>{option.name || option.username}</span>
<span>
{option.name && option.username
? option.username
: option.email}
</span>
</StyledOption>
</StrechedLi>
);
const renderTags = (value: AvailableReviewerSchema[]) => (
<StyledTags>
{value.length > 1
? `${value.length} users selected`
: value[0].name || value[0].username || value[0].email}
</StyledTags>
);
export const DraftChangeRequestActions: FC<{
environmentChangeRequest: ChangeRequestType;
reviewers: AvailableReviewerSchema[];
setReviewers: React.Dispatch<
React.SetStateAction<AvailableReviewerSchema[]>
>;
onReview: (changeState: (project: string) => Promise<void>) => void;
onDiscard: (id: number) => void;
sendToReview: (project: string) => Promise<void>;
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<AvailableReviewerSchema>,
) =>
options.filter(
({ name, username, email }) =>
caseInsensitiveSearch(inputValue, email) ||
caseInsensitiveSearch(inputValue, name) ||
caseInsensitiveSearch(inputValue, username),
);
return (
<>
<AutocompleteVirtual
sx={{ ml: 'auto', width: theme.spacing(40) }}
size='small'
limitTags={3}
openOnFocus
multiple
disableCloseOnSelect
value={reviewers as AvailableReviewerSchema[]}
onChange={autoCompleteChange}
options={availableReviewers}
renderOption={renderOption}
filterOptions={filterOptions}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option: AvailableReviewerSchema) =>
option.email || option.name || option.username || ''
}
renderInput={(params) => (
<TextField
{...params}
label={`Reviewers (${reviewers.length})`}
/>
)}
renderTags={(value) => renderTags(value)}
noOptionsText={isLoading ? 'Loading…' : 'No options'}
/>
<SubmitChangeRequestButton
onClick={() => onReview(sendToReview)}
count={changesCount(environmentChangeRequest)}
disabled={disabled}
/>
<Button
sx={{ ml: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => {
setDisabled(true);
onDiscard(environmentChangeRequest.id);
}}
>
Discard changes
</Button>
</>
);
};

View File

@ -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<AvailableReviewerSchema[]>([]);
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<{
<ConditionallyRender
condition={environmentChangeRequest?.state === 'Draft'}
show={
<>
<SubmitChangeRequestButton
onClick={() => onReview(sendToReview)}
count={changesCount(
environmentChangeRequest,
)}
disabled={disabled}
/>
<ConditionallyRender
condition={approversEnabled}
show={
<DraftChangeRequestActions
environmentChangeRequest={
environmentChangeRequest
}
reviewers={reviewers}
setReviewers={setReviewers}
onReview={onReview}
onDiscard={onDiscard}
sendToReview={sendToReview}
disabled={disabled}
setDisabled={setDisabled}
/>
}
elseShow={
<>
<SubmitChangeRequestButton
onClick={() =>
onReview(sendToReview)
}
count={changesCount(
environmentChangeRequest,
)}
disabled={disabled}
/>
<Button
sx={{ ml: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => {
setDisabled(true);
onDiscard(environmentChangeRequest.id);
}}
>
Discard changes
</Button>
</>
<Button
sx={{ ml: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => {
setDisabled(true);
onDiscard(
environmentChangeRequest.id,
);
}}
>
Discard changes
</Button>
</>
}
/>
}
/>
<ConditionallyRender

View File

@ -139,6 +139,14 @@ export const DemoSteps = ({
}
}
step.onStep?.({
el,
index,
next,
step,
signal: abortController.signal,
});
if (!step.nextButton) {
const clickHandler = (e: Event) => {
abortController.abort();

View File

@ -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: (
<Description>
Add a new constraint value by using this button.
</Description>
),
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: (
<Description>
Add the constraint value by using this button.
</Description>
),
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: (
<Description>
Add a new constraint value by using this button.
</Description>
),
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: (
<Description>
Add the constraint value by using this button.
</Description>
),
optional: true,
},
{
title: 'Save constraint setup',

View File

@ -130,6 +130,7 @@ export const AddValuesPopover: FC<AddValuesProps> = ({
inputProps={{
...inputProps,
}}
data-testid='CONSTRAINT_VALUES_INPUT'
/>
<AddButton
variant='text'
@ -137,6 +138,7 @@ export const AddValuesPopover: FC<AddValuesProps> = ({
size='small'
color='primary'
disabled={!inputValue?.trim()}
data-testid='CONSTRAINT_VALUES_ADD_BUTTON'
>
Add
</AddButton>

View File

@ -74,6 +74,7 @@ export const AddValuesWidget = forwardRef<HTMLButtonElement, AddValuesProps>(
ref={positioningRef}
onClick={() => setOpen(true)}
type='button'
data-testid='CONSTRAINT_ADD_VALUES_BUTTON'
>
<Add />
<span>Add values</span>

View File

@ -137,6 +137,11 @@ export const ProjectHealthChart: FC<IProjectHealthChartProps> = ({
: 'health',
xAxisKey: 'date',
},
plugins: {
legend: {
display: !isAggregate,
},
},
scales: {
y: {
min: 0,

View File

@ -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 (
<DynamicSidebarModal
@ -161,9 +159,6 @@ export const ProjectStatusModal = ({ open, onClose, onFollowLink }: Props) => {
</HeaderRow>
<WidgetContainer>
<Row>
<RowHeader>
{healthToDebtEnabled ? 'Technical debt' : 'Health'}
</RowHeader>
<ProjectHealthGrid />
</Row>
{!isOss() && (

View File

@ -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,
};

View File

@ -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());
};

View File

@ -91,6 +91,7 @@ export type UiFlags = {
healthToTechDebt?: boolean;
improvedJsonDiff?: boolean;
impactMetrics?: boolean;
changeRequestApproverEmails?: boolean;
};
export interface IVersionInfo {

View File

@ -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

View File

@ -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",

View File

@ -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 youd 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, youll 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 youd 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, youll 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: [
{

View File

@ -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: [

View File

@ -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;

View File

@ -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';

View File

@ -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];

View File

@ -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';

View File

@ -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 {

View File

@ -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,

View File

@ -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';

View File

@ -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<IEvent[]> {
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);

View File

@ -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';

View File

@ -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++) {

View File

@ -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<IUnleashConfig, 'getLogger'>,
{
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
eventService: EventService,
) {
this.logger = getLogger('feature-links/feature-link-service.ts');
this.featureLinkStore = stores.featureLinkStore;
this.eventService = eventService;
this.flagResolver = flagResolver;
}
async getAll(): Promise<IFeatureLink[]> {
@ -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,

View File

@ -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(

View File

@ -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';

View File

@ -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',
);
};

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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(),

View File

@ -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}`);
});
}

View File

@ -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.",
},
},
},

View File

@ -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<IUnleashConfig, 'authentication'>,
@ -232,6 +233,7 @@ export async function createApp(
scheduleServices(services, config);
}
defineImpactMetrics(config.flagResolver);
const metricsMonitor = fm.createMetricsMonitor();
const unleashSession = fm.createSessionDb(config, db);

View File

@ -15,6 +15,7 @@ export interface IEventSearchParams {
createdBy?: string;
type?: string;
environment?: string;
order?: 'asc' | 'desc'; // desc by default
offset: number;
limit: number;
}

View File

@ -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 = <T = string>(

View File

@ -0,0 +1,34 @@
export const fakeImpactMetricsResolver = () => ({
counters: new Map<string, { value: number; help: string }>(),
gauges: new Map<string, { value: number; help: string }>(),
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);
},
});

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.
:::

View File

@ -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.

View File

@ -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).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 171 KiB

View File

@ -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"

View File

@ -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"