Period
@@ -47,7 +60,7 @@ export const FeatureMetricsHours = ({
name='feature-metrics-period'
id='feature-metrics-period'
options={options}
- value={String(hoursBack)}
+ value={String(normalizedHoursBack)}
onChange={onChange}
fullWidth
/>
@@ -70,24 +83,17 @@ const hourOptions: { key: `${number}`; label: string }[] = [
},
];
-const now = new Date();
-
const daysOptions: { key: `${number}`; label: string }[] = [
{
- key: `${differenceInHours(now, subWeeks(now, 1))}`,
- label: 'Last week',
+ key: `${7 * 24}`,
+ label: 'Last 7 days',
},
{
- key: `${differenceInHours(now, subMonths(now, 1))}`,
- label: 'Last month',
+ key: `${30 * 24}`,
+ label: 'Last 30 days',
},
{
- key: `${differenceInHours(now, subMonths(now, 3))}`,
- label: 'Last 3 months',
+ key: `${90 * 24}`,
+ label: 'Last 90 days',
},
];
-
-export const FEATURE_METRIC_HOURS_BACK_MAX = differenceInHours(
- now,
- subMonths(now, 3),
-);
diff --git a/frontend/src/component/feedbackNew/FeedbackComponent.tsx b/frontend/src/component/feedbackNew/FeedbackComponent.tsx
index 6916e69c997b..41aeaa052213 100644
--- a/frontend/src/component/feedbackNew/FeedbackComponent.tsx
+++ b/frontend/src/component/feedbackNew/FeedbackComponent.tsx
@@ -225,7 +225,7 @@ export const FeedbackComponent = ({
setHasSubmittedFeedback(true);
trackEvent('feedback', {
props: {
- eventType: `dont ask again`,
+ eventType: `dont ask again - ${feedbackData.category}`,
category: feedbackData.category,
},
});
@@ -242,6 +242,12 @@ export const FeedbackComponent = ({
if (isProvideFeedbackSchema(data)) {
try {
await addFeedback(data as ProvideFeedbackSchema);
+ trackEvent('feedback', {
+ props: {
+ eventType: `submitted - ${feedbackData.category}`,
+ category: feedbackData.category,
+ },
+ });
toastTitle = 'Feedback sent';
toastType = 'success';
setHasSubmittedFeedback(true);
@@ -258,6 +264,12 @@ export const FeedbackComponent = ({
const [selectedScore, setSelectedScore] = useState
(null);
const onScoreChange = (event: React.ChangeEvent) => {
+ trackEvent('feedback', {
+ props: {
+ eventType: `score change - ${feedbackData.category}`,
+ category: feedbackData.category,
+ },
+ });
setSelectedScore(event.target.value);
};
diff --git a/frontend/src/component/feedbackNew/FeedbackProvider.tsx b/frontend/src/component/feedbackNew/FeedbackProvider.tsx
index b37a028924f4..965758bd4a01 100644
--- a/frontend/src/component/feedbackNew/FeedbackProvider.tsx
+++ b/frontend/src/component/feedbackNew/FeedbackProvider.tsx
@@ -20,13 +20,19 @@ export const FeedbackProvider: FC = ({ children }) => {
trackEvent('feedback', {
props: {
- eventType: `feedback opened`,
+ eventType: `feedback opened - ${data.category}`,
category: data.category,
},
});
};
const closeFeedback = () => {
+ trackEvent('feedback', {
+ props: {
+ eventType: `feedback closed - ${feedbackData?.category}`,
+ category: feedbackData?.category || 'unknown',
+ },
+ });
setFeedbackData(undefined);
setShowFeedback(false);
};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksForm.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksForm.tsx
new file mode 100644
index 000000000000..b0a6c5bd511d
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksForm.tsx
@@ -0,0 +1,248 @@
+import {
+ Alert,
+ FormControl,
+ FormControlLabel,
+ Link,
+ Radio,
+ RadioGroup,
+ styled,
+} from '@mui/material';
+import Input from 'component/common/Input/Input';
+import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IIncomingWebhook } from 'interfaces/incomingWebhook';
+import {
+ IncomingWebhooksFormErrors,
+ TokenGeneration,
+} from './useIncomingWebhooksForm';
+import { IncomingWebhooksFormURL } from './IncomingWebhooksFormURL';
+import { IncomingWebhooksTokens } from './IncomingWebhooksTokens/IncomingWebhooksTokens';
+
+const StyledRaisedSection = styled('div')(({ theme }) => ({
+ background: theme.palette.background.elevation1,
+ padding: theme.spacing(2, 3),
+ borderRadius: theme.shape.borderRadiusLarge,
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledInputDescription = styled('p')(({ theme }) => ({
+ display: 'flex',
+ color: theme.palette.text.primary,
+ marginBottom: theme.spacing(1),
+ '&:not(:first-of-type)': {
+ marginTop: theme.spacing(4),
+ },
+}));
+
+const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+}));
+
+const StyledInput = styled(Input)(({ theme }) => ({
+ width: '100%',
+ maxWidth: theme.spacing(50),
+}));
+
+const StyledSecondarySection = styled('div')(({ theme }) => ({
+ padding: theme.spacing(3),
+ backgroundColor: theme.palette.background.elevation2,
+ borderRadius: theme.shape.borderRadiusMedium,
+ marginTop: theme.spacing(4),
+ marginBottom: theme.spacing(2),
+}));
+
+const StyledInlineContainer = styled('div')(({ theme }) => ({
+ padding: theme.spacing(0, 4),
+ '& > p:not(:first-of-type)': {
+ marginTop: theme.spacing(2),
+ },
+}));
+
+interface IIncomingWebhooksFormProps {
+ incomingWebhook?: IIncomingWebhook;
+ enabled: boolean;
+ setEnabled: React.Dispatch>;
+ name: string;
+ setName: React.Dispatch>;
+ description: string;
+ setDescription: React.Dispatch>;
+ tokenGeneration: TokenGeneration;
+ setTokenGeneration: React.Dispatch>;
+ tokenName: string;
+ setTokenName: React.Dispatch>;
+ errors: IncomingWebhooksFormErrors;
+ validateName: (name: string) => boolean;
+ validateTokenName: (
+ tokenGeneration: TokenGeneration,
+ name: string,
+ ) => boolean;
+ validated: boolean;
+}
+
+export const IncomingWebhooksForm = ({
+ incomingWebhook,
+ enabled,
+ setEnabled,
+ name,
+ setName,
+ description,
+ setDescription,
+ tokenGeneration,
+ setTokenGeneration,
+ tokenName,
+ setTokenName,
+ errors,
+ validateName,
+ validateTokenName,
+ validated,
+}: IIncomingWebhooksFormProps) => {
+ const handleOnBlur = (callback: Function) => {
+ setTimeout(() => callback(), 300);
+ };
+
+ const showErrors = validated && Object.values(errors).some(Boolean);
+
+ return (
+
+
+
+
+ Incoming webhook status
+
+
+
+ What is your new incoming webhook name?
+
+
{
+ validateName(e.target.value);
+ setName(e.target.value);
+ }}
+ onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
+ autoComplete='off'
+ />
+
+ What is your new incoming webhook description?
+
+ setDescription(e.target.value)}
+ autoComplete='off'
+ />
+
+ Token
+
+ In order to connect your newly created incoming
+ webhook, you will also need a token.{' '}
+
+ Read more about API tokens
+
+ .
+
+
+ {
+ const tokenGeneration = e.target
+ .value as TokenGeneration;
+
+ if (validated) {
+ validateTokenName(
+ tokenGeneration,
+ tokenName,
+ );
+ }
+ setTokenGeneration(tokenGeneration);
+ }}
+ name='token-generation'
+ >
+ }
+ label='I want to generate a token later'
+ />
+ }
+ label='Generate a token now'
+ />
+
+
+
+
+ A new incoming webhook token will be generated
+ for the incoming webhook, so you can get started
+ right away.
+
+
+
+ What is your new token name?
+
+ {
+ validateTokenName(
+ tokenGeneration,
+ e.target.value,
+ );
+ setTokenName(e.target.value);
+ }}
+ autoComplete='off'
+ />
+ >
+ }
+ />
+
+
+ }
+ elseShow={
+ <>
+
+ Incoming webhook tokens
+
+
+ >
+ }
+ />
+ (
+
+
+ {Object.values(errors)
+ .filter(Boolean)
+ .map((error) => (
+ - {error}
+ ))}
+
+
+ )}
+ />
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksFormURL.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksFormURL.tsx
new file mode 100644
index 000000000000..bd378715d35b
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksFormURL.tsx
@@ -0,0 +1,70 @@
+import { IconButton, Tooltip, styled } from '@mui/material';
+import CopyIcon from '@mui/icons-material/FileCopy';
+import copy from 'copy-to-clipboard';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import useToast from 'hooks/useToast';
+
+const StyledIncomingWebhookUrlSection = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: theme.spacing(1.5),
+ gap: theme.spacing(1.5),
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadiusMedium,
+ marginBottom: theme.spacing(4),
+}));
+
+const StyledIncomingWebhookUrlSectionDescription = styled('p')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+}));
+
+const StyledIncomingWebhookUrl = styled('div')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ backgroundColor: theme.palette.background.elevation2,
+ padding: theme.spacing(1),
+ width: '100%',
+ borderRadius: theme.shape.borderRadiusMedium,
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ wordBreak: 'break-all',
+}));
+
+interface IIncomingWebhooksFormURLProps {
+ name: string;
+}
+
+export const IncomingWebhooksFormURL = ({
+ name,
+}: IIncomingWebhooksFormURLProps) => {
+ const { uiConfig } = useUiConfig();
+ const { setToastData } = useToast();
+
+ const url = `${uiConfig.unleashUrl}/api/incoming-webhook/${name}`;
+
+ const onCopyToClipboard = () => {
+ copy(url);
+ setToastData({
+ type: 'success',
+ title: 'Copied to clipboard',
+ });
+ };
+
+ return (
+
+
+ Incoming webhook URL:
+
+
+ {url}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokens.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokens.tsx
new file mode 100644
index 000000000000..794fc2cccfa4
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokens.tsx
@@ -0,0 +1,305 @@
+import { Delete } from '@mui/icons-material';
+import {
+ Button,
+ IconButton,
+ styled,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material';
+import { Search } from 'component/common/Search/Search';
+import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { PAT_LIMIT } from '@server/util/constants';
+import { useIncomingWebhookTokens } from 'hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens';
+import { useSearch } from 'hooks/useSearch';
+import { useMemo, useState } from 'react';
+import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
+import { sortTypes } from 'utils/sortTypes';
+import { IncomingWebhooksTokensCreateDialog } from './IncomingWebhooksTokensCreateDialog';
+import { IncomingWebhooksTokensDialog } from './IncomingWebhooksTokensDialog';
+import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import {
+ IncomingWebhookTokenPayload,
+ useIncomingWebhookTokensApi,
+} from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import {
+ IIncomingWebhook,
+ IIncomingWebhookToken,
+} from 'interfaces/incomingWebhook';
+import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
+
+const StyledHeader = styled('div')(({ theme }) => ({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: theme.spacing(2),
+ gap: theme.spacing(2),
+ '& > div': {
+ [theme.breakpoints.down('md')]: {
+ marginTop: 0,
+ },
+ },
+}));
+
+const StyledTablePlaceholder = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: theme.spacing(3),
+}));
+
+const StyledPlaceholderTitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.bodySize,
+ marginBottom: theme.spacing(0.5),
+}));
+
+const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1.5),
+}));
+
+export type PageQueryType = Partial<
+ Record<'sort' | 'order' | 'search', string>
+>;
+
+const defaultSort: SortingRule = { id: 'createdAt', desc: true };
+
+interface IIncomingWebhooksTokensProps {
+ incomingWebhook: IIncomingWebhook;
+}
+
+export const IncomingWebhooksTokens = ({
+ incomingWebhook,
+}: IIncomingWebhooksTokensProps) => {
+ const theme = useTheme();
+ const { setToastData, setToastApiError } = useToast();
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const { incomingWebhookTokens, refetch: refetchTokens } =
+ useIncomingWebhookTokens(incomingWebhook.id);
+ const { refetch } = useIncomingWebhooks();
+ const { addIncomingWebhookToken, removeIncomingWebhookToken } =
+ useIncomingWebhookTokensApi();
+
+ const [initialState] = useState(() => ({
+ sortBy: [defaultSort],
+ }));
+
+ const [searchValue, setSearchValue] = useState('');
+ const [createOpen, setCreateOpen] = useState(false);
+ const [tokenOpen, setTokenOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [newToken, setNewToken] = useState('');
+ const [selectedToken, setSelectedToken] = useState();
+
+ const onCreateClick = async (newToken: IncomingWebhookTokenPayload) => {
+ try {
+ const { token } = await addIncomingWebhookToken(
+ incomingWebhook.id,
+ newToken,
+ );
+ refetch();
+ refetchTokens();
+ setCreateOpen(false);
+ setNewToken(token);
+ setTokenOpen(true);
+ setToastData({
+ title: 'Token created successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const onDeleteClick = async () => {
+ if (selectedToken) {
+ try {
+ await removeIncomingWebhookToken(
+ incomingWebhook.id,
+ selectedToken.id,
+ );
+ refetch();
+ refetchTokens();
+ setDeleteOpen(false);
+ setToastData({
+ title: 'Token deleted successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: 'Name',
+ accessor: 'name',
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ Cell: DateCell,
+ maxWidth: 150,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original: rowToken } }: any) => (
+
+
+
+ {
+ setSelectedToken(rowToken);
+ setDeleteOpen(true);
+ }}
+ >
+
+
+
+
+
+ ),
+ maxWidth: 100,
+ disableSortBy: true,
+ },
+ ],
+ [setSelectedToken, setDeleteOpen],
+ );
+
+ const { data, getSearchText, getSearchContext } = useSearch(
+ columns,
+ searchValue,
+ incomingWebhookTokens,
+ );
+
+ const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
+ {
+ columns: columns as any[],
+ data,
+ initialState,
+ sortTypes,
+ autoResetHiddenColumns: false,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ disableMultiSort: true,
+ },
+ useSortBy,
+ useFlexLayout,
+ );
+
+ useConditionallyHiddenColumns(
+ [
+ {
+ condition: isSmallScreen,
+ columns: ['createdAt'],
+ },
+ ],
+ setHiddenColumns,
+ columns,
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+ 0}
+ show={
+
+ No tokens found matching “
+ {searchValue}
+ ”
+
+ }
+ elseShow={
+
+
+ You have no tokens for this incoming webhook
+ yet.
+
+
+ Create a token to start using this incoming
+ webhook.
+
+
+
+ }
+ />
+ }
+ />
+
+
+ {
+ setDeleteOpen(false);
+ }}
+ title='Delete token?'
+ >
+
+ Any applications or scripts using this token "
+ {selectedToken?.name}" will no longer be
+ able to make requests to this incoming webhook. You cannot
+ undo this action.
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensCreateDialog.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensCreateDialog.tsx
new file mode 100644
index 000000000000..6e94f60515e7
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensCreateDialog.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import { IncomingWebhookTokenPayload } from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
+import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
+import { styled } from '@mui/material';
+import Input from 'component/common/Input/Input';
+
+const StyledForm = styled('div')(({ theme }) => ({
+ minHeight: theme.spacing(12),
+}));
+
+const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1),
+}));
+
+const StyledInput = styled(Input)(({ theme }) => ({
+ width: '100%',
+ maxWidth: theme.spacing(50),
+}));
+
+interface IIncomingWebhooksTokensCreateDialogProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ tokens: IIncomingWebhookToken[];
+ onCreateClick: (newToken: IncomingWebhookTokenPayload) => void;
+}
+
+export const IncomingWebhooksTokensCreateDialog = ({
+ open,
+ setOpen,
+ tokens,
+ onCreateClick,
+}: IIncomingWebhooksTokensCreateDialogProps) => {
+ const [name, setName] = useState('');
+
+ const [nameError, setNameError] = useState('');
+
+ useEffect(() => {
+ setName('');
+ setNameError('');
+ }, [open]);
+
+ const isNameUnique = (name: string) =>
+ !tokens?.some((token) => token.name === name);
+
+ const validateName = (name: string) => {
+ if (!name.length) {
+ setNameError('Name is required');
+ } else if (!isNameUnique(name)) {
+ setNameError('Name must be unique');
+ } else {
+ setNameError('');
+ }
+ };
+
+ const isValid = name.length && isNameUnique(name);
+
+ return (
+
+ onCreateClick({
+ name,
+ })
+ }
+ disabledPrimaryButton={!isValid}
+ onClose={() => {
+ setOpen(false);
+ }}
+ title='New token'
+ >
+
+
+ What is your new token name?
+
+ {
+ validateName(e.target.value);
+ setName(e.target.value);
+ }}
+ autoComplete='off'
+ />
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog.tsx
new file mode 100644
index 000000000000..9e3b76ae9fa8
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog.tsx
@@ -0,0 +1,37 @@
+import { Alert, styled, Typography } from '@mui/material';
+import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+
+const StyledAlert = styled(Alert)(({ theme }) => ({
+ marginBottom: theme.spacing(3),
+}));
+
+interface IIncomingWebhooksTokensDialogProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ token?: string;
+}
+
+export const IncomingWebhooksTokensDialog = ({
+ open,
+ setOpen,
+ token,
+}: IIncomingWebhooksTokensDialogProps) => (
+ {
+ if (!muiCloseReason) {
+ setOpen(false);
+ }
+ }}
+ title='Incoming webhook token created'
+ >
+
+ Make sure to copy your incoming webhook token now. You won't be able
+ to see it again!
+
+ Your token:
+
+
+);
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/useIncomingWebhooksForm.ts b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/useIncomingWebhooksForm.ts
new file mode 100644
index 000000000000..5642ad87235f
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksForm/useIncomingWebhooksForm.ts
@@ -0,0 +1,134 @@
+import { URL_SAFE_BASIC } from '@server/util/constants';
+import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
+import { IIncomingWebhook } from 'interfaces/incomingWebhook';
+import { useEffect, useState } from 'react';
+
+enum ErrorField {
+ NAME = 'name',
+ TOKEN_NAME = 'tokenName',
+}
+
+const DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS = {
+ [ErrorField.NAME]: undefined,
+ [ErrorField.TOKEN_NAME]: undefined,
+};
+
+export type IncomingWebhooksFormErrors = Record;
+
+export enum TokenGeneration {
+ LATER = 'later',
+ NOW = 'now',
+}
+
+export const useIncomingWebhooksForm = (incomingWebhook?: IIncomingWebhook) => {
+ const { incomingWebhooks } = useIncomingWebhooks();
+
+ const [enabled, setEnabled] = useState(false);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [tokenGeneration, setTokenGeneration] = useState(
+ TokenGeneration.LATER,
+ );
+ const [tokenName, setTokenName] = useState('');
+
+ const reloadForm = () => {
+ setEnabled(incomingWebhook?.enabled ?? true);
+ setName(incomingWebhook?.name || '');
+ setDescription(incomingWebhook?.description || '');
+ setTokenGeneration(TokenGeneration.LATER);
+ setTokenName('');
+ setValidated(false);
+ setErrors(DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS);
+ };
+
+ useEffect(() => {
+ reloadForm();
+ }, [incomingWebhook]);
+
+ const [errors, setErrors] = useState(
+ DEFAULT_INCOMING_WEBHOOKS_FORM_ERRORS,
+ );
+ const [validated, setValidated] = useState(false);
+
+ const clearError = (field: ErrorField) => {
+ setErrors((errors) => ({ ...errors, [field]: undefined }));
+ };
+
+ const setError = (field: ErrorField, error: string) => {
+ setErrors((errors) => ({ ...errors, [field]: error }));
+ };
+
+ const isEmpty = (value: string) => !value.length;
+
+ const isNameNotUnique = (value: string) =>
+ incomingWebhooks?.some(
+ ({ id, name }) => id !== incomingWebhook?.id && name === value,
+ );
+
+ const isNameInvalid = (value: string) => !URL_SAFE_BASIC.test(value);
+
+ const validateName = (name: string) => {
+ if (isEmpty(name)) {
+ setError(ErrorField.NAME, 'Name is required.');
+ return false;
+ }
+
+ if (isNameNotUnique(name)) {
+ setError(ErrorField.NAME, 'Name must be unique.');
+ return false;
+ }
+
+ if (isNameInvalid(name)) {
+ setError(
+ ErrorField.NAME,
+ 'Name must only contain alphanumeric lowercase characters, dashes and underscores.',
+ );
+ return false;
+ }
+
+ clearError(ErrorField.NAME);
+ return true;
+ };
+
+ const validateTokenName = (
+ tokenGeneration: TokenGeneration,
+ tokenName: string,
+ ) => {
+ if (tokenGeneration === TokenGeneration.NOW && isEmpty(tokenName)) {
+ setError(ErrorField.TOKEN_NAME, 'Token name is required.');
+ return false;
+ }
+
+ clearError(ErrorField.TOKEN_NAME);
+ return true;
+ };
+
+ const validate = () => {
+ const validName = validateName(name);
+ const validTokenName = validateTokenName(tokenGeneration, tokenName);
+
+ setValidated(true);
+
+ return validName && validTokenName;
+ };
+
+ return {
+ enabled,
+ setEnabled,
+ name,
+ setName,
+ description,
+ setDescription,
+ tokenGeneration,
+ setTokenGeneration,
+ tokenName,
+ setTokenName,
+ errors,
+ setErrors,
+ validated,
+ validateName,
+ validateTokenName,
+ validate,
+ reloadForm,
+ };
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
new file mode 100644
index 000000000000..4301b7300b73
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
@@ -0,0 +1,184 @@
+import { FormEvent, useEffect } from 'react';
+import { Button, styled } from '@mui/material';
+import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
+import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
+import FormTemplate from 'component/common/FormTemplate/FormTemplate';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+import { IIncomingWebhook } from 'interfaces/incomingWebhook';
+import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
+import {
+ IncomingWebhookPayload,
+ useIncomingWebhooksApi,
+} from 'hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi';
+import { useIncomingWebhookTokensApi } from 'hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi';
+import { IncomingWebhooksForm } from './IncomingWebhooksForm/IncomingWebhooksForm';
+import {
+ TokenGeneration,
+ useIncomingWebhooksForm,
+} from './IncomingWebhooksForm/useIncomingWebhooksForm';
+
+const StyledForm = styled('form')(() => ({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+}));
+
+const StyledButtonContainer = styled('div')(({ theme }) => ({
+ marginTop: 'auto',
+ display: 'flex',
+ justifyContent: 'flex-end',
+ paddingTop: theme.spacing(4),
+}));
+
+const StyledCancelButton = styled(Button)(({ theme }) => ({
+ marginLeft: theme.spacing(3),
+}));
+
+interface IIncomingWebhooksModalProps {
+ incomingWebhook?: IIncomingWebhook;
+ open: boolean;
+ setOpen: React.Dispatch>;
+ newToken: (token: string) => void;
+}
+
+export const IncomingWebhooksModal = ({
+ incomingWebhook,
+ open,
+ setOpen,
+ newToken,
+}: IIncomingWebhooksModalProps) => {
+ const { refetch } = useIncomingWebhooks();
+ const { addIncomingWebhook, updateIncomingWebhook, loading } =
+ useIncomingWebhooksApi();
+ const { addIncomingWebhookToken } = useIncomingWebhookTokensApi();
+ const { setToastData, setToastApiError } = useToast();
+ const { uiConfig } = useUiConfig();
+
+ const {
+ enabled,
+ setEnabled,
+ name,
+ setName,
+ description,
+ setDescription,
+ tokenGeneration,
+ setTokenGeneration,
+ tokenName,
+ setTokenName,
+ errors,
+ validateName,
+ validateTokenName,
+ validate,
+ validated,
+ reloadForm,
+ } = useIncomingWebhooksForm(incomingWebhook);
+
+ useEffect(() => {
+ reloadForm();
+ }, [open]);
+
+ const editing = incomingWebhook !== undefined;
+ const title = `${editing ? 'Edit' : 'New'} incoming webhook`;
+
+ const payload: IncomingWebhookPayload = {
+ enabled,
+ name,
+ description,
+ };
+
+ const formatApiCode = () => `curl --location --request ${
+ editing ? 'PUT' : 'POST'
+ } '${uiConfig.unleashUrl}/api/admin/incoming-webhooks${
+ editing ? `/${incomingWebhook.id}` : ''
+ }' \\
+ --header 'Authorization: INSERT_API_KEY' \\
+ --header 'Content-Type: application/json' \\
+ --data-raw '${JSON.stringify(payload, undefined, 2)}'`;
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+
+ if (!validate()) return;
+
+ try {
+ if (editing) {
+ await updateIncomingWebhook(incomingWebhook.id, payload);
+ } else {
+ const { id } = await addIncomingWebhook(payload);
+ if (tokenGeneration === TokenGeneration.NOW) {
+ const { token } = await addIncomingWebhookToken(id, {
+ name: tokenName,
+ });
+ newToken(token);
+ }
+ }
+ setToastData({
+ title: `Incoming webhook ${
+ editing ? 'updated' : 'added'
+ } successfully`,
+ type: 'success',
+ });
+ refetch();
+ setOpen(false);
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ return (
+ {
+ setOpen(false);
+ }}
+ label={title}
+ >
+
+
+
+
+
+ {
+ setOpen(false);
+ }}
+ >
+ Cancel
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx
index 0396fb7e365c..b59f9034130c 100644
--- a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx
@@ -20,7 +20,8 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli
import copy from 'copy-to-clipboard';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { IncomingWebhookTokensCell } from './IncomingWebhooksTokensCell';
-// import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
+import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
+import { IncomingWebhooksTokensDialog } from '../IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog';
interface IIncomingWebhooksTableProps {
modalOpen: boolean;
@@ -40,10 +41,12 @@ export const IncomingWebhooksTable = ({
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
- const { incomingWebhooks, refetch, loading } = useIncomingWebhooks();
+ const { incomingWebhooks, refetch } = useIncomingWebhooks();
const { toggleIncomingWebhook, removeIncomingWebhook } =
useIncomingWebhooksApi();
+ const [tokenDialog, setTokenDialog] = useState(false);
+ const [newToken, setNewToken] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const onToggleIncomingWebhook = async (
@@ -123,7 +126,7 @@ export const IncomingWebhooksTable = ({
/>
),
searchable: true,
- maxWidth: 100,
+ maxWidth: 120,
},
{
Header: 'Created',
@@ -233,11 +236,20 @@ export const IncomingWebhooksTable = ({
}
/>
- {/* */}
+ newToken={(token: string) => {
+ setNewToken(token);
+ setTokenDialog(true);
+ }}
+ />
+
= ({
}
diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
index 3ecab6df3b56..d2821aabe817 100644
--- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
+++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
@@ -426,5 +426,14 @@ exports[`returns all baseRoutes 1`] = `
"title": "Profile",
"type": "protected",
},
+ {
+ "component": [Function],
+ "enterprise": true,
+ "flag": "executiveDashboard",
+ "menu": {},
+ "path": "/dashboard",
+ "title": "Executive dashboard",
+ "type": "protected",
+ },
]
`;
diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts
index db22e0d4e27c..26be4509c552 100644
--- a/frontend/src/component/menu/routes.ts
+++ b/frontend/src/component/menu/routes.ts
@@ -45,6 +45,7 @@ import { FeatureTypesList } from 'component/featureTypes/FeatureTypesList';
import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewIntegration';
import { ApplicationList } from '../application/ApplicationList/ApplicationList';
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
+import { ExecutiveDashboard } from 'component/executiveDashboard/ExecutiveDashboard';
export const routes: IRoute[] = [
// Splash
@@ -429,6 +430,17 @@ export const routes: IRoute[] = [
menu: {},
},
+ // Executive dashboard
+ {
+ path: '/dashboard',
+ title: 'Executive dashboard',
+ component: ExecutiveDashboard,
+ type: 'protected',
+ menu: {},
+ flag: 'executiveDashboard',
+ enterprise: true,
+ },
+
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
{
path: '/login',
diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx
index 45529fc76f24..573f3afda23f 100644
--- a/frontend/src/component/project/Project/Project.tsx
+++ b/frontend/src/component/project/Project/Project.tsx
@@ -64,7 +64,7 @@ export const Project = () => {
const projectId = useRequiredPathParam('projectId');
const params = useQueryParams();
const { project, loading, error, refetch } = useProject(projectId);
- const ref = useLoading(loading);
+ const ref = useLoading(loading, '[data-loading-project=true]');
const { setToastData, setToastApiError } = useToast();
const [modalOpen, setModalOpen] = useState(false);
const navigate = useNavigate();
@@ -193,7 +193,7 @@ export const Project = () => {
condition={project?.mode === 'private'}
show={}
/>
-
+
{projectName}
@@ -210,7 +210,7 @@ export const Project = () => {
onClick={() => setModalOpen(true)}
tooltipProps={{ title: 'Import' }}
data-testid={IMPORT_BUTTON}
- data-loading
+ data-loading-project
>
@@ -232,7 +232,7 @@ export const Project = () => {
{filteredTabs.map((tab) => {
return (
{
query.get('redirect') || getSessionStorageItem('login-redirect') || '/';
if (user) {
+ setSessionStorageItem('login-redirect');
return ;
}
diff --git a/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts b/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts
index 1e8b6ad179f4..ccf384574af1 100644
--- a/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts
+++ b/frontend/src/hooks/api/actions/useIncomingWebhookTokensApi/useIncomingWebhookTokensApi.ts
@@ -3,12 +3,12 @@ import useAPI from '../useApi/useApi';
const ENDPOINT = 'api/admin/incoming-webhooks';
-export type AddOrUpdateIncomingWebhookToken = Omit<
+export type IncomingWebhookTokenPayload = Omit<
IIncomingWebhookToken,
'id' | 'incomingWebhookId' | 'createdAt' | 'createdByUserId'
>;
-export type IncomingWebhookTokenWithTokenSecret = IIncomingWebhookToken & {
+type IncomingWebhookTokenWithTokenSecret = IIncomingWebhookToken & {
token: string;
};
@@ -19,7 +19,7 @@ export const useIncomingWebhookTokensApi = () => {
const addIncomingWebhookToken = async (
incomingWebhookId: number,
- incomingWebhookToken: AddOrUpdateIncomingWebhookToken,
+ incomingWebhookToken: IncomingWebhookTokenPayload,
): Promise => {
const requestId = 'addIncomingWebhookToken';
const req = createRequest(
@@ -38,7 +38,7 @@ export const useIncomingWebhookTokensApi = () => {
const updateIncomingWebhookToken = async (
incomingWebhookId: number,
incomingWebhookTokenId: number,
- incomingWebhookToken: AddOrUpdateIncomingWebhookToken,
+ incomingWebhookToken: IncomingWebhookTokenPayload,
) => {
const requestId = 'updateIncomingWebhookToken';
const req = createRequest(
diff --git a/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts b/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts
index ce1f9a5b394b..de5773f7a344 100644
--- a/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts
+++ b/frontend/src/hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi.ts
@@ -3,9 +3,9 @@ import useAPI from '../useApi/useApi';
const ENDPOINT = 'api/admin/incoming-webhooks';
-export type AddOrUpdateIncomingWebhook = Omit<
+export type IncomingWebhookPayload = Omit<
IIncomingWebhook,
- 'id' | 'createdAt' | 'createdByUserId'
+ 'id' | 'createdAt' | 'createdByUserId' | 'tokens'
>;
export const useIncomingWebhooksApi = () => {
@@ -14,7 +14,7 @@ export const useIncomingWebhooksApi = () => {
});
const addIncomingWebhook = async (
- incomingWebhook: AddOrUpdateIncomingWebhook,
+ incomingWebhook: IncomingWebhookPayload,
) => {
const requestId = 'addIncomingWebhook';
const req = createRequest(
@@ -32,7 +32,7 @@ export const useIncomingWebhooksApi = () => {
const updateIncomingWebhook = async (
incomingWebhookId: number,
- incomingWebhook: AddOrUpdateIncomingWebhook,
+ incomingWebhook: IncomingWebhookPayload,
) => {
const requestId = 'updateIncomingWebhook';
const req = createRequest(
diff --git a/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts b/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts
index c9283c18d1e3..cf1042cd1e28 100644
--- a/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts
+++ b/frontend/src/hooks/api/getters/useIncomingWebhookTokens/useIncomingWebhookTokens.ts
@@ -4,14 +4,16 @@ import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
import { IIncomingWebhookToken } from 'interfaces/incomingWebhook';
+import { useUiFlag } from 'hooks/useUiFlag';
const ENDPOINT = 'api/admin/incoming-webhooks';
export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
const { isEnterprise } = useUiConfig();
+ const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const { data, error, mutate } = useConditionalSWR(
- isEnterprise(),
+ isEnterprise() && incomingWebhooksEnabled,
{ incomingWebhookTokens: [] },
formatApiPath(`${ENDPOINT}/${incomingWebhookId}/tokens`),
fetcher,
@@ -19,7 +21,7 @@ export const useIncomingWebhookTokens = (incomingWebhookId: number) => {
return useMemo(
() => ({
- incomingWebhookTokens: (data?.incomingWebhooks ??
+ incomingWebhookTokens: (data?.incomingWebhookTokens ??
[]) as IIncomingWebhookToken[],
loading: !error && !data,
refetch: () => mutate(),
diff --git a/frontend/src/hooks/usePersistentTableState.test.tsx b/frontend/src/hooks/usePersistentTableState.test.tsx
index 16fa6a29c948..eaf94f4232b1 100644
--- a/frontend/src/hooks/usePersistentTableState.test.tsx
+++ b/frontend/src/hooks/usePersistentTableState.test.tsx
@@ -3,7 +3,7 @@ import { screen, waitFor } from '@testing-library/react';
import { usePersistentTableState } from './usePersistentTableState';
import { Route, Routes } from 'react-router-dom';
import { createLocalStorage } from '../utils/createLocalStorage';
-import { NumberParam, StringParam } from 'use-query-params';
+import { ArrayParam, NumberParam, StringParam } from 'use-query-params';
import { FilterItemParam } from '../utils/serializeQueryParams';
type TestComponentProps = {
@@ -87,6 +87,7 @@ describe('usePersistentTableState', () => {
createLocalStorage('testKey', {}).setValue({
query: 'initialStorage',
filterItem: { operator: 'IS', values: ['default'] },
+ columns: ['a', 'b'],
});
render(
@@ -95,6 +96,7 @@ describe('usePersistentTableState', () => {
queryParamsDefinition={{
query: StringParam,
filterItem: FilterItemParam,
+ columns: ArrayParam,
}}
/>,
{ route: '/my-url' },
@@ -104,7 +106,7 @@ describe('usePersistentTableState', () => {
'initialStorage',
);
expect(window.location.href).toContain(
- 'my-url?query=initialStorage&filterItem=IS%3Adefault',
+ 'my-url?query=initialStorage&filterItem=IS%3Adefault&columns=a&columns=b',
);
});
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 2f615b02aad3..ecf118c4a8f2 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -75,6 +75,7 @@ export type UiFlags = {
newStrategyConfigurationFeedback?: boolean;
extendedUsageMetricsUI?: boolean;
adminTokenKillSwitch?: boolean;
+ executiveDashboard?: boolean;
};
export interface IVersionInfo {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 36a7951b72d4..ab90f1ddea1f 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1513,10 +1513,10 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
-"@remix-run/router@1.14.1":
- version "1.14.1"
- resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.14.1.tgz#6d2dd03d52e604279c38911afc1079d58c50a755"
- integrity sha512-Qg4DMQsfPNAs88rb2xkdk03N3bjK4jgX5fR24eHCTR9q6PrhZQZ4UJBPzCHJkIpTRN1UKxx2DzjZmnC+7Lj0Ow==
+"@remix-run/router@1.14.2":
+ version "1.14.2"
+ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.14.2.tgz#4d58f59908d9197ba3179310077f25c88e49ed17"
+ integrity sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==
"@replit/codemirror-indentation-markers@^6.5.0":
version "6.5.0"
@@ -1897,16 +1897,16 @@
svg-parser "^2.0.4"
"@tanstack/react-table@^8.10.7":
- version "8.11.3"
- resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.11.3.tgz#0af6f7d074a319920f5149eba0a64a5b71125368"
- integrity sha512-Gwwm7po1MaObBguw69L+UiACkaj+eOtThQEArj/3fmUwMPiWaJcXvNG2X5Te5z2hg0HMx8h0T0Q7p5YmQlTUfw==
+ version "8.11.6"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.11.6.tgz#ae234355eaae230ede95ebcbadb3251482eb6087"
+ integrity sha512-i0heTVZtuHF9VPMwcYoZ21hbzGDmLjxHYemDMvbGpjk5fUZ8nLF3S8qjVRU79XfPW8KK9o7iTU2fGFVQQmxMSQ==
dependencies:
- "@tanstack/table-core" "8.11.3"
+ "@tanstack/table-core" "8.11.6"
-"@tanstack/table-core@8.11.3":
- version "8.11.3"
- resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.11.3.tgz#89b2914b2bc6f1aaffd31582e268a52273c49179"
- integrity sha512-nkcFIL696wTf1QMvhGR7dEg60OIRwEZm1OqFTYYDTRc4JOWspgrsJO3IennsOJ7ptumHWLDjV8e5BjPkZcSZAQ==
+"@tanstack/table-core@8.11.6":
+ version "8.11.6"
+ resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.11.6.tgz#ab753617b64d72f868f6ea4666059912f0c98441"
+ integrity sha512-69WEY1PaZROaGYUrseng4/4sMYnRGhDe1vM6888CnWekGz/wuCnvqwOoOuKGYivnaiI4BVmZq4WKWhvahyj3/g==
"@testing-library/dom@8.20.1":
version "8.20.1"
@@ -2127,6 +2127,13 @@
dependencies:
"@types/lodash" "*"
+"@types/lodash.isequal@^4.5.8":
+ version "4.5.8"
+ resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz#b30bb6ff6a5f6c19b3daf389d649ac7f7a250499"
+ integrity sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==
+ dependencies:
+ "@types/lodash" "*"
+
"@types/lodash.mapvalues@^4.6.9":
version "4.6.9"
resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.9.tgz#1edb4b1d299db332166b474221b06058b34030a7"
@@ -5289,6 +5296,11 @@ lodash.isempty@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e"
integrity sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==
+lodash.isequal@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+ integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
+
lodash.mapvalues@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
@@ -6478,20 +6490,20 @@ react-refresh@^0.14.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==
-react-router-dom@6.21.1:
- version "6.21.1"
- resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.21.1.tgz#58b459d2fe1841388c95bb068f85128c45e27349"
- integrity sha512-QCNrtjtDPwHDO+AO21MJd7yIcr41UetYt5jzaB9Y1UYaPTCnVuJq6S748g1dE11OQlCFIQg+RtAA1SEZIyiBeA==
+react-router-dom@6.21.2:
+ version "6.21.2"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.21.2.tgz#5fba851731a194fa32c31990c4829c5e247f650a"
+ integrity sha512-tE13UukgUOh2/sqYr6jPzZTzmzc70aGRP4pAjG2if0IP3aUT+sBtAKUJh0qMh0zylJHGLmzS+XWVaON4UklHeg==
dependencies:
- "@remix-run/router" "1.14.1"
- react-router "6.21.1"
+ "@remix-run/router" "1.14.2"
+ react-router "6.21.2"
-react-router@6.21.1:
- version "6.21.1"
- resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.21.1.tgz#8db7ee8d7cfc36513c9a66b44e0897208c33be34"
- integrity sha512-W0l13YlMTm1YrpVIOpjCADJqEUpz1vm+CMo47RuFX4Ftegwm6KOYsL5G3eiE52jnJpKvzm6uB/vTKTPKM8dmkA==
+react-router@6.21.2:
+ version "6.21.2"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.21.2.tgz#8820906c609ae7e4e8f926cc8eb5ce161428b956"
+ integrity sha512-jJcgiwDsnaHIeC+IN7atO0XiSRCrOsQAHHbChtJxmgqG2IaYQXSnhqGb5vk2CU/wBQA12Zt+TkbuJjIn65gzbA==
dependencies:
- "@remix-run/router" "1.14.1"
+ "@remix-run/router" "1.14.2"
react-shallow-renderer@^16.13.1:
version "16.15.0"
diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap
index bbdf4a19d3af..707c8911d108 100644
--- a/src/lib/__snapshots__/create-config.test.ts.snap
+++ b/src/lib/__snapshots__/create-config.test.ts.snap
@@ -90,6 +90,7 @@ exports[`should create default config 1`] = `
"embedProxyFrontend": true,
"enableLicense": false,
"encryptEmails": false,
+ "executiveDashboard": false,
"extendedUsageMetrics": false,
"extendedUsageMetricsUI": false,
"featureSearchAPI": false,
diff --git a/src/lib/create-config.ts b/src/lib/create-config.ts
index 4cc2ff2c01bd..c5383d029a78 100644
--- a/src/lib/create-config.ts
+++ b/src/lib/create-config.ts
@@ -553,7 +553,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
const dailyMetricsStorageDays = Math.min(
parseEnvVarNumber(process.env.DAILY_METRICS_STORAGE_DAYS, 31),
- 92,
+ 91,
);
return {
diff --git a/src/lib/db/event-store.ts b/src/lib/db/event-store.ts
index d3132efe3a21..074d9b30edff 100644
--- a/src/lib/db/event-store.ts
+++ b/src/lib/db/event-store.ts
@@ -1,433 +1,4 @@
-import {
- IEvent,
- IBaseEvent,
- SEGMENT_UPDATED,
- FEATURE_IMPORT,
- FEATURES_IMPORTED,
- IEventType,
-} from '../types/events';
-import { LogProvider, Logger } from '../logger';
-import { IEventStore } from '../types/stores/event-store';
-import { ITag } from '../types/model';
-import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
-import { sharedEventEmitter } from '../util/anyEventEmitter';
-import { Db } from './db';
-import { Knex } from 'knex';
-import EventEmitter from 'events';
-
-const EVENT_COLUMNS = [
- 'id',
- 'type',
- 'created_by',
- 'created_at',
- 'created_by_user_id',
- 'data',
- 'pre_data',
- 'tags',
- 'feature_name',
- 'project',
- 'environment',
-] as const;
-
-export type IQueryOperations =
- | IWhereOperation
- | IBeforeDateOperation
- | IBetweenDatesOperation
- | IForFeaturesOperation;
-
-interface IWhereOperation {
- op: 'where';
- parameters: {
- [key: string]: string;
- };
-}
-
-interface IBeforeDateOperation {
- op: 'beforeDate';
- parameters: {
- dateAccessor: string;
- date: string;
- };
-}
-
-interface IBetweenDatesOperation {
- op: 'betweenDate';
- parameters: {
- dateAccessor: string;
- range: string[];
- };
-}
-
-interface IForFeaturesOperation {
- op: 'forFeatures';
- parameters: IForFeaturesParams;
-}
-
-interface IForFeaturesParams {
- type: string;
- projectId: string;
- environments: string[];
- features: string[];
-}
-
-export interface IEventTable {
- id: number;
- type: string;
- created_by: string;
- created_at: Date;
- created_by_user_id: number;
- data?: any;
- pre_data?: any;
- feature_name?: string;
- project?: string;
- environment?: string;
- tags: ITag[];
-}
-
-const TABLE = 'events';
-
-class EventStore implements IEventStore {
- private db: Db;
-
- // only one shared event emitter should exist across all event store instances
- private eventEmitter: EventEmitter = sharedEventEmitter;
-
- private logger: Logger;
-
- // a new DB has to be injected per transaction
- constructor(db: Db, getLogger: LogProvider) {
- this.db = db;
- this.logger = getLogger('lib/db/event-store.ts');
- }
-
- async store(event: IBaseEvent): Promise {
- try {
- await this.db(TABLE)
- .insert(this.eventToDbRow(event))
- .returning(EVENT_COLUMNS);
- } catch (error: unknown) {
- this.logger.warn(`Failed to store "${event.type}" event: ${error}`);
- }
- }
-
- async count(): Promise {
- const count = await this.db(TABLE)
- .count>()
- .first();
- if (!count) {
- return 0;
- }
- if (typeof count.count === 'string') {
- return parseInt(count.count, 10);
- } else {
- return count.count;
- }
- }
-
- async filteredCount(eventSearch: SearchEventsSchema): Promise {
- let query = this.db(TABLE);
- if (eventSearch.type) {
- query = query.andWhere({ type: eventSearch.type });
- }
- if (eventSearch.project) {
- query = query.andWhere({ project: eventSearch.project });
- }
- if (eventSearch.feature) {
- query = query.andWhere({ feature_name: eventSearch.feature });
- }
- const count = await query.count().first();
- if (!count) {
- return 0;
- }
- if (typeof count.count === 'string') {
- return parseInt(count.count, 10);
- } else {
- return count.count;
- }
- }
-
- async batchStore(events: IBaseEvent[]): Promise {
- try {
- await this.db(TABLE).insert(events.map(this.eventToDbRow));
- } catch (error: unknown) {
- this.logger.warn(`Failed to store events: ${error}`);
- }
- }
-
- async getMaxRevisionId(largerThan: number = 0): Promise {
- const row = await this.db(TABLE)
- .max('id')
- .where((builder) =>
- builder
- .whereNotNull('feature_name')
- .orWhereIn('type', [
- SEGMENT_UPDATED,
- FEATURE_IMPORT,
- FEATURES_IMPORTED,
- ]),
- )
- .andWhere('id', '>=', largerThan)
- .first();
- return row?.max ?? 0;
- }
-
- async delete(key: number): Promise {
- await this.db(TABLE).where({ id: key }).del();
- }
-
- async deleteAll(): Promise {
- await this.db(TABLE).del();
- }
-
- destroy(): void {}
-
- async exists(key: number): Promise {
- const result = await this.db.raw(
- `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
- [key],
- );
- const { present } = result.rows[0];
- return present;
- }
-
- async query(operations: IQueryOperations[]): Promise {
- try {
- let query: Knex.QueryBuilder = this.select();
-
- operations.forEach((operation) => {
- if (operation.op === 'where') {
- query = this.where(query, operation.parameters);
- }
-
- if (operation.op === 'forFeatures') {
- query = this.forFeatures(query, operation.parameters);
- }
-
- if (operation.op === 'beforeDate') {
- query = this.beforeDate(query, operation.parameters);
- }
-
- if (operation.op === 'betweenDate') {
- query = this.betweenDate(query, operation.parameters);
- }
- });
-
- const rows = await query;
- return rows.map(this.rowToEvent);
- } catch (e) {
- return [];
- }
- }
-
- async queryCount(operations: IQueryOperations[]): Promise {
- try {
- let query: Knex.QueryBuilder = this.db.count().from(TABLE);
-
- operations.forEach((operation) => {
- if (operation.op === 'where') {
- query = this.where(query, operation.parameters);
- }
-
- if (operation.op === 'forFeatures') {
- query = this.forFeatures(query, operation.parameters);
- }
-
- if (operation.op === 'beforeDate') {
- query = this.beforeDate(query, operation.parameters);
- }
-
- if (operation.op === 'betweenDate') {
- query = this.betweenDate(query, operation.parameters);
- }
- });
-
- const queryResult = await query.first();
- return parseInt(queryResult.count || 0);
- } catch (e) {
- return 0;
- }
- }
-
- where(
- query: Knex.QueryBuilder,
- parameters: { [key: string]: string },
- ): Knex.QueryBuilder {
- return query.where(parameters);
- }
-
- beforeDate(
- query: Knex.QueryBuilder,
- parameters: { dateAccessor: string; date: string },
- ): Knex.QueryBuilder {
- return query.andWhere(parameters.dateAccessor, '>=', parameters.date);
- }
-
- betweenDate(
- query: Knex.QueryBuilder,
- parameters: { dateAccessor: string; range: string[] },
- ): Knex.QueryBuilder {
- if (parameters.range && parameters.range.length === 2) {
- return query.andWhereBetween(parameters.dateAccessor, [
- parameters.range[0],
- parameters.range[1],
- ]);
- }
-
- return query;
- }
-
- select(): Knex.QueryBuilder {
- return this.db.select(EVENT_COLUMNS).from(TABLE);
- }
-
- forFeatures(
- query: Knex.QueryBuilder,
- parameters: IForFeaturesParams,
- ): Knex.QueryBuilder {
- return query
- .where({ type: parameters.type, project: parameters.projectId })
- .whereIn('feature_name', parameters.features)
- .whereIn('environment', parameters.environments);
- }
-
- async get(key: number): Promise {
- const row = await this.db(TABLE).where({ id: key }).first();
- return this.rowToEvent(row);
- }
-
- async getAll(query?: Object): Promise {
- return this.getEvents(query);
- }
-
- async getEvents(query?: Object): Promise {
- try {
- let qB = this.db
- .select(EVENT_COLUMNS)
- .from(TABLE)
- .limit(100)
- .orderBy('created_at', 'desc');
- if (query) {
- qB = qB.where(query);
- }
- const rows = await qB;
- return rows.map(this.rowToEvent);
- } catch (err) {
- return [];
- }
- }
-
- async searchEvents(search: SearchEventsSchema = {}): Promise {
- let query = this.db
- .select(EVENT_COLUMNS)
- .from(TABLE)
- .limit(search.limit ?? 100)
- .offset(search.offset ?? 0)
- .orderBy('created_at', 'desc');
-
- if (search.type) {
- query = query.andWhere({
- type: search.type,
- });
- }
-
- if (search.project) {
- query = query.andWhere({
- project: search.project,
- });
- }
-
- if (search.feature) {
- query = query.andWhere({
- feature_name: search.feature,
- });
- }
-
- if (search.query) {
- query = query.where((where) =>
- where
- .orWhereRaw('type::text ILIKE ?', `%${search.query}%`)
- .orWhereRaw('created_by::text ILIKE ?', `%${search.query}%`)
- .orWhereRaw('data::text ILIKE ?', `%${search.query}%`)
- .orWhereRaw('tags::text ILIKE ?', `%${search.query}%`)
- .orWhereRaw('pre_data::text ILIKE ?', `%${search.query}%`),
- );
- }
-
- try {
- return (await query).map(this.rowToEvent);
- } catch (err) {
- return [];
- }
- }
-
- rowToEvent(row: IEventTable): IEvent {
- return {
- id: row.id,
- type: row.type as IEventType,
- createdBy: row.created_by,
- createdAt: row.created_at,
- createdByUserId: row.created_by_user_id,
- data: row.data,
- preData: row.pre_data,
- tags: row.tags || [],
- featureName: row.feature_name,
- project: row.project,
- environment: row.environment,
- };
- }
-
- eventToDbRow(e: IBaseEvent): Omit {
- return {
- type: e.type,
- created_by: e.createdBy ?? 'admin',
- created_by_user_id: e.createdByUserId,
- data: Array.isArray(e.data) ? JSON.stringify(e.data) : e.data,
- pre_data: Array.isArray(e.preData)
- ? JSON.stringify(e.preData)
- : e.preData,
- // @ts-expect-error workaround for json-array
- tags: JSON.stringify(e.tags),
- feature_name: e.featureName,
- project: e.project,
- environment: e.environment,
- };
- }
-
- setMaxListeners(number: number): EventEmitter {
- return this.eventEmitter.setMaxListeners(number);
- }
-
- on(
- eventName: string | symbol,
- listener: (...args: any[]) => void,
- ): EventEmitter {
- return this.eventEmitter.on(eventName, listener);
- }
-
- emit(eventName: string | symbol, ...args: any[]): boolean {
- return this.eventEmitter.emit(eventName, ...args);
- }
-
- off(
- eventName: string | symbol,
- listener: (...args: any[]) => void,
- ): EventEmitter {
- return this.eventEmitter.off(eventName, listener);
- }
-
- async setUnannouncedToAnnounced(): Promise {
- const rows = await this.db(TABLE)
- .update({ announced: true })
- .where('announced', false)
- .returning(EVENT_COLUMNS);
- return rows.map(this.rowToEvent);
- }
-
- async publishUnannouncedEvents(): Promise {
- const events = await this.setUnannouncedToAnnounced();
-
- events.forEach((e) => this.eventEmitter.emit(e.type, e));
- }
-}
-
+import EventStore from '../features/events/event-store';
+// For backward compatibility
+export * from '../features/events/event-store';
export default EventStore;
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index ea06d9dc5b9e..715666f5edbb 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -1,6 +1,6 @@
import { IUnleashConfig, IUnleashStores } from '../types';
-import EventStore from './event-store';
+import EventStore from '../features/events/event-store';
import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store';
import FeatureTypeStore from './feature-type-store';
import StrategyStore from './strategy-store';
diff --git a/src/lib/features/events/createEventsService.ts b/src/lib/features/events/createEventsService.ts
index 04412797b530..9f1a238d1da1 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';
import FakeFeatureTagStore from '../../../test/fixtures/fake-feature-tag-store';
import { Db } from '../../db/db';
-import EventStore from '../../db/event-store';
+import EventStore from './event-store';
import FeatureTagStore from '../../db/feature-tag-store';
import { EventService } from '../../services';
import { IUnleashConfig } from '../../types';
diff --git a/src/lib/services/event-service.test.ts b/src/lib/features/events/event-service.test.ts
similarity index 72%
rename from src/lib/services/event-service.test.ts
rename to src/lib/features/events/event-service.test.ts
index bd74a862517f..d89115f12335 100644
--- a/src/lib/services/event-service.test.ts
+++ b/src/lib/features/events/event-service.test.ts
@@ -1,7 +1,7 @@
-import { ADMIN_TOKEN_USER, IApiUser } from '../types';
-import { createTestConfig } from '../../test/config/test-config';
-import { createFakeEventsService } from '../../lib/features';
-import { ApiTokenType } from '../../lib/types/models/api-token';
+import { ADMIN_TOKEN_USER, IApiUser } from '../../types';
+import { createTestConfig } from '../../../test/config/test-config';
+import { createFakeEventsService } from '..';
+import { ApiTokenType } from '../../types/models/api-token';
test('when using an admin token should get the username of the token and the id from internalAdminTokenUserId', async () => {
const adminToken: IApiUser = {
diff --git a/src/lib/features/events/event-service.ts b/src/lib/features/events/event-service.ts
new file mode 100644
index 000000000000..6d2c3b935bce
--- /dev/null
+++ b/src/lib/features/events/event-service.ts
@@ -0,0 +1,137 @@
+import { IUnleashConfig } from '../../types/option';
+import { IFeatureTagStore, IUnleashStores } from '../../types/stores';
+import { Logger } from '../../logger';
+import { IEventStore } from '../../types/stores/event-store';
+import { IBaseEvent, IEventList, IUserEvent } from '../../types/events';
+import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
+import EventEmitter from 'events';
+import { IApiUser, ITag, IUser } from '../../types';
+import { ApiTokenType } from '../../types/models/api-token';
+import {
+ extractUserIdFromUser,
+ extractUsernameFromUser,
+} from '../../util/extract-user';
+
+export default class EventService {
+ private logger: Logger;
+
+ private eventStore: IEventStore;
+
+ private featureTagStore: IFeatureTagStore;
+
+ constructor(
+ {
+ eventStore,
+ featureTagStore,
+ }: Pick,
+ { getLogger }: Pick,
+ ) {
+ this.logger = getLogger('services/event-service.ts');
+ this.eventStore = eventStore;
+ this.featureTagStore = featureTagStore;
+ }
+
+ async getEvents(): Promise {
+ const totalEvents = await this.eventStore.count();
+ const events = await this.eventStore.getEvents();
+ return {
+ events,
+ totalEvents,
+ };
+ }
+
+ async searchEvents(search: SearchEventsSchema): Promise {
+ const totalEvents = await this.eventStore.filteredCount(search);
+ const events = await this.eventStore.searchEvents(search);
+ return {
+ events,
+ totalEvents,
+ };
+ }
+
+ async onEvent(
+ eventName: string | symbol,
+ listener: (...args: any[]) => void,
+ ): Promise {
+ return this.eventStore.on(eventName, listener);
+ }
+
+ private async enhanceEventsWithTags(
+ events: IBaseEvent[],
+ ): Promise {
+ const featureNamesSet = new Set();
+ for (const event of events) {
+ if (event.featureName && !event.tags) {
+ featureNamesSet.add(event.featureName);
+ }
+ }
+
+ const featureTagsMap: Map = new Map();
+ const allTagsInFeatures = await this.featureTagStore.getAllByFeatures(
+ Array.from(featureNamesSet),
+ );
+
+ for (const tag of allTagsInFeatures) {
+ const featureTags = featureTagsMap.get(tag.featureName) || [];
+ featureTags.push({ value: tag.tagValue, type: tag.tagType });
+ featureTagsMap.set(tag.featureName, featureTags);
+ }
+
+ for (const event of events) {
+ if (event.featureName && !event.tags) {
+ event.tags = featureTagsMap.get(event.featureName);
+ }
+ }
+
+ return events;
+ }
+
+ isAdminToken(user: IUser | IApiUser): boolean {
+ return (user as IApiUser)?.type === ApiTokenType.ADMIN;
+ }
+
+ getUserDetails(user: IUser | IApiUser): {
+ createdBy: string;
+ createdByUserId: number;
+ } {
+ return {
+ createdBy: extractUsernameFromUser(user),
+ createdByUserId: extractUserIdFromUser(user),
+ };
+ }
+
+ /**
+ * @deprecated use storeUserEvent instead
+ */
+ async storeEvent(event: IBaseEvent): Promise {
+ return this.storeEvents([event]);
+ }
+
+ /**
+ * @deprecated use storeUserEvents instead
+ */
+ async storeEvents(events: IBaseEvent[]): Promise {
+ let enhancedEvents = events;
+ for (const enhancer of [this.enhanceEventsWithTags.bind(this)]) {
+ enhancedEvents = await enhancer(enhancedEvents);
+ }
+ return this.eventStore.batchStore(enhancedEvents);
+ }
+
+ async storeUserEvent(event: IUserEvent): Promise {
+ return this.storeUserEvents([event]);
+ }
+
+ async storeUserEvents(events: IUserEvent[]): Promise {
+ let enhancedEvents = events.map(({ byUser, ...event }) => {
+ return {
+ ...event,
+ ...this.getUserDetails(byUser),
+ };
+ });
+ for (const enhancer of [this.enhanceEventsWithTags.bind(this)]) {
+ enhancedEvents = await enhancer(enhancedEvents);
+ }
+ return this.eventStore.batchStore(enhancedEvents);
+ }
+}
diff --git a/src/lib/db/event-store.test.ts b/src/lib/features/events/event-store.test.ts
similarity index 93%
rename from src/lib/db/event-store.test.ts
rename to src/lib/features/events/event-store.test.ts
index a38263944276..07524beb0511 100644
--- a/src/lib/db/event-store.test.ts
+++ b/src/lib/features/events/event-store.test.ts
@@ -1,8 +1,8 @@
import knex from 'knex';
import EventStore from './event-store';
-import getLogger from '../../test/fixtures/no-logger';
+import getLogger from '../../../test/fixtures/no-logger';
import { subHours, formatRFC3339 } from 'date-fns';
-import dbInit from '../../test/e2e/helpers/database-init';
+import dbInit from '../../../test/e2e/helpers/database-init';
beforeAll(() => {
getLogger.setMuteError(true);
diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts
new file mode 100644
index 000000000000..457b4007be3f
--- /dev/null
+++ b/src/lib/features/events/event-store.ts
@@ -0,0 +1,433 @@
+import {
+ IEvent,
+ IBaseEvent,
+ SEGMENT_UPDATED,
+ FEATURE_IMPORT,
+ FEATURES_IMPORTED,
+ IEventType,
+} from '../../types/events';
+import { LogProvider, Logger } from '../../logger';
+import { IEventStore } from '../../types/stores/event-store';
+import { ITag } from '../../types/model';
+import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
+import { sharedEventEmitter } from '../../util/anyEventEmitter';
+import { Db } from '../../db/db';
+import { Knex } from 'knex';
+import EventEmitter from 'events';
+
+const EVENT_COLUMNS = [
+ 'id',
+ 'type',
+ 'created_by',
+ 'created_at',
+ 'created_by_user_id',
+ 'data',
+ 'pre_data',
+ 'tags',
+ 'feature_name',
+ 'project',
+ 'environment',
+] as const;
+
+export type IQueryOperations =
+ | IWhereOperation
+ | IBeforeDateOperation
+ | IBetweenDatesOperation
+ | IForFeaturesOperation;
+
+interface IWhereOperation {
+ op: 'where';
+ parameters: {
+ [key: string]: string;
+ };
+}
+
+interface IBeforeDateOperation {
+ op: 'beforeDate';
+ parameters: {
+ dateAccessor: string;
+ date: string;
+ };
+}
+
+interface IBetweenDatesOperation {
+ op: 'betweenDate';
+ parameters: {
+ dateAccessor: string;
+ range: string[];
+ };
+}
+
+interface IForFeaturesOperation {
+ op: 'forFeatures';
+ parameters: IForFeaturesParams;
+}
+
+interface IForFeaturesParams {
+ type: string;
+ projectId: string;
+ environments: string[];
+ features: string[];
+}
+
+export interface IEventTable {
+ id: number;
+ type: string;
+ created_by: string;
+ created_at: Date;
+ created_by_user_id: number;
+ data?: any;
+ pre_data?: any;
+ feature_name?: string;
+ project?: string;
+ environment?: string;
+ tags: ITag[];
+}
+
+const TABLE = 'events';
+
+class EventStore implements IEventStore {
+ private db: Db;
+
+ // only one shared event emitter should exist across all event store instances
+ private eventEmitter: EventEmitter = sharedEventEmitter;
+
+ private logger: Logger;
+
+ // a new DB has to be injected per transaction
+ constructor(db: Db, getLogger: LogProvider) {
+ this.db = db;
+ this.logger = getLogger('event-store');
+ }
+
+ async store(event: IBaseEvent): Promise {
+ try {
+ await this.db(TABLE)
+ .insert(this.eventToDbRow(event))
+ .returning(EVENT_COLUMNS);
+ } catch (error: unknown) {
+ this.logger.warn(`Failed to store "${event.type}" event: ${error}`);
+ }
+ }
+
+ async count(): Promise {
+ const count = await this.db(TABLE)
+ .count>()
+ .first();
+ if (!count) {
+ return 0;
+ }
+ if (typeof count.count === 'string') {
+ return parseInt(count.count, 10);
+ } else {
+ return count.count;
+ }
+ }
+
+ async filteredCount(eventSearch: SearchEventsSchema): Promise {
+ let query = this.db(TABLE);
+ if (eventSearch.type) {
+ query = query.andWhere({ type: eventSearch.type });
+ }
+ if (eventSearch.project) {
+ query = query.andWhere({ project: eventSearch.project });
+ }
+ if (eventSearch.feature) {
+ query = query.andWhere({ feature_name: eventSearch.feature });
+ }
+ const count = await query.count().first();
+ if (!count) {
+ return 0;
+ }
+ if (typeof count.count === 'string') {
+ return parseInt(count.count, 10);
+ } else {
+ return count.count;
+ }
+ }
+
+ async batchStore(events: IBaseEvent[]): Promise {
+ try {
+ await this.db(TABLE).insert(events.map(this.eventToDbRow));
+ } catch (error: unknown) {
+ this.logger.warn(`Failed to store events: ${error}`);
+ }
+ }
+
+ async getMaxRevisionId(largerThan: number = 0): Promise {
+ const row = await this.db(TABLE)
+ .max('id')
+ .where((builder) =>
+ builder
+ .whereNotNull('feature_name')
+ .orWhereIn('type', [
+ SEGMENT_UPDATED,
+ FEATURE_IMPORT,
+ FEATURES_IMPORTED,
+ ]),
+ )
+ .andWhere('id', '>=', largerThan)
+ .first();
+ return row?.max ?? 0;
+ }
+
+ async delete(key: number): Promise {
+ await this.db(TABLE).where({ id: key }).del();
+ }
+
+ async deleteAll(): Promise {
+ await this.db(TABLE).del();
+ }
+
+ destroy(): void {}
+
+ async exists(key: number): Promise {
+ const result = await this.db.raw(
+ `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
+ [key],
+ );
+ const { present } = result.rows[0];
+ return present;
+ }
+
+ async query(operations: IQueryOperations[]): Promise {
+ try {
+ let query: Knex.QueryBuilder = this.select();
+
+ operations.forEach((operation) => {
+ if (operation.op === 'where') {
+ query = this.where(query, operation.parameters);
+ }
+
+ if (operation.op === 'forFeatures') {
+ query = this.forFeatures(query, operation.parameters);
+ }
+
+ if (operation.op === 'beforeDate') {
+ query = this.beforeDate(query, operation.parameters);
+ }
+
+ if (operation.op === 'betweenDate') {
+ query = this.betweenDate(query, operation.parameters);
+ }
+ });
+
+ const rows = await query;
+ return rows.map(this.rowToEvent);
+ } catch (e) {
+ return [];
+ }
+ }
+
+ async queryCount(operations: IQueryOperations[]): Promise {
+ try {
+ let query: Knex.QueryBuilder = this.db.count().from(TABLE);
+
+ operations.forEach((operation) => {
+ if (operation.op === 'where') {
+ query = this.where(query, operation.parameters);
+ }
+
+ if (operation.op === 'forFeatures') {
+ query = this.forFeatures(query, operation.parameters);
+ }
+
+ if (operation.op === 'beforeDate') {
+ query = this.beforeDate(query, operation.parameters);
+ }
+
+ if (operation.op === 'betweenDate') {
+ query = this.betweenDate(query, operation.parameters);
+ }
+ });
+
+ const queryResult = await query.first();
+ return parseInt(queryResult.count || 0);
+ } catch (e) {
+ return 0;
+ }
+ }
+
+ where(
+ query: Knex.QueryBuilder,
+ parameters: { [key: string]: string },
+ ): Knex.QueryBuilder {
+ return query.where(parameters);
+ }
+
+ beforeDate(
+ query: Knex.QueryBuilder,
+ parameters: { dateAccessor: string; date: string },
+ ): Knex.QueryBuilder {
+ return query.andWhere(parameters.dateAccessor, '>=', parameters.date);
+ }
+
+ betweenDate(
+ query: Knex.QueryBuilder,
+ parameters: { dateAccessor: string; range: string[] },
+ ): Knex.QueryBuilder {
+ if (parameters.range && parameters.range.length === 2) {
+ return query.andWhereBetween(parameters.dateAccessor, [
+ parameters.range[0],
+ parameters.range[1],
+ ]);
+ }
+
+ return query;
+ }
+
+ select(): Knex.QueryBuilder {
+ return this.db.select(EVENT_COLUMNS).from(TABLE);
+ }
+
+ forFeatures(
+ query: Knex.QueryBuilder,
+ parameters: IForFeaturesParams,
+ ): Knex.QueryBuilder {
+ return query
+ .where({ type: parameters.type, project: parameters.projectId })
+ .whereIn('feature_name', parameters.features)
+ .whereIn('environment', parameters.environments);
+ }
+
+ async get(key: number): Promise {
+ const row = await this.db(TABLE).where({ id: key }).first();
+ return this.rowToEvent(row);
+ }
+
+ async getAll(query?: Object): Promise {
+ return this.getEvents(query);
+ }
+
+ async getEvents(query?: Object): Promise {
+ try {
+ let qB = this.db
+ .select(EVENT_COLUMNS)
+ .from(TABLE)
+ .limit(100)
+ .orderBy('created_at', 'desc');
+ if (query) {
+ qB = qB.where(query);
+ }
+ const rows = await qB;
+ return rows.map(this.rowToEvent);
+ } catch (err) {
+ return [];
+ }
+ }
+
+ async searchEvents(search: SearchEventsSchema = {}): Promise {
+ let query = this.db
+ .select(EVENT_COLUMNS)
+ .from(TABLE)
+ .limit(search.limit ?? 100)
+ .offset(search.offset ?? 0)
+ .orderBy('created_at', 'desc');
+
+ if (search.type) {
+ query = query.andWhere({
+ type: search.type,
+ });
+ }
+
+ if (search.project) {
+ query = query.andWhere({
+ project: search.project,
+ });
+ }
+
+ if (search.feature) {
+ query = query.andWhere({
+ feature_name: search.feature,
+ });
+ }
+
+ if (search.query) {
+ query = query.where((where) =>
+ where
+ .orWhereRaw('type::text ILIKE ?', `%${search.query}%`)
+ .orWhereRaw('created_by::text ILIKE ?', `%${search.query}%`)
+ .orWhereRaw('data::text ILIKE ?', `%${search.query}%`)
+ .orWhereRaw('tags::text ILIKE ?', `%${search.query}%`)
+ .orWhereRaw('pre_data::text ILIKE ?', `%${search.query}%`),
+ );
+ }
+
+ try {
+ return (await query).map(this.rowToEvent);
+ } catch (err) {
+ return [];
+ }
+ }
+
+ rowToEvent(row: IEventTable): IEvent {
+ return {
+ id: row.id,
+ type: row.type as IEventType,
+ createdBy: row.created_by,
+ createdAt: row.created_at,
+ createdByUserId: row.created_by_user_id,
+ data: row.data,
+ preData: row.pre_data,
+ tags: row.tags || [],
+ featureName: row.feature_name,
+ project: row.project,
+ environment: row.environment,
+ };
+ }
+
+ eventToDbRow(e: IBaseEvent): Omit {
+ return {
+ type: e.type,
+ created_by: e.createdBy ?? 'admin',
+ created_by_user_id: e.createdByUserId,
+ data: Array.isArray(e.data) ? JSON.stringify(e.data) : e.data,
+ pre_data: Array.isArray(e.preData)
+ ? JSON.stringify(e.preData)
+ : e.preData,
+ // @ts-expect-error workaround for json-array
+ tags: JSON.stringify(e.tags),
+ feature_name: e.featureName,
+ project: e.project,
+ environment: e.environment,
+ };
+ }
+
+ setMaxListeners(number: number): EventEmitter {
+ return this.eventEmitter.setMaxListeners(number);
+ }
+
+ on(
+ eventName: string | symbol,
+ listener: (...args: any[]) => void,
+ ): EventEmitter {
+ return this.eventEmitter.on(eventName, listener);
+ }
+
+ emit(eventName: string | symbol, ...args: any[]): boolean {
+ return this.eventEmitter.emit(eventName, ...args);
+ }
+
+ off(
+ eventName: string | symbol,
+ listener: (...args: any[]) => void,
+ ): EventEmitter {
+ return this.eventEmitter.off(eventName, listener);
+ }
+
+ async setUnannouncedToAnnounced(): Promise {
+ const rows = await this.db(TABLE)
+ .update({ announced: true })
+ .where('announced', false)
+ .returning(EVENT_COLUMNS);
+ return rows.map(this.rowToEvent);
+ }
+
+ async publishUnannouncedEvents(): Promise {
+ const events = await this.setUnannouncedToAnnounced();
+
+ events.forEach((e) => this.eventEmitter.emit(e.type, e));
+ }
+}
+
+export default EventStore;
diff --git a/src/lib/features/export-import-toggles/createExportImportService.ts b/src/lib/features/export-import-toggles/createExportImportService.ts
index 7623b34fddbf..29636f392aa7 100644
--- a/src/lib/features/export-import-toggles/createExportImportService.ts
+++ b/src/lib/features/export-import-toggles/createExportImportService.ts
@@ -38,7 +38,7 @@ import FakeEventStore from '../../../test/fixtures/fake-event-store';
import FakeFeatureStrategiesStore from '../feature-toggle/fakes/fake-feature-strategies-store';
import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store';
import FakeStrategiesStore from '../../../test/fixtures/fake-strategies-store';
-import EventStore from '../../db/event-store';
+import EventStore from '../events/event-store';
import {
createFakePrivateProjectChecker,
createPrivateProjectChecker,
diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts
index 23a165a32ba4..226a2dd6b3e0 100644
--- a/src/lib/features/feature-toggle/feature-toggle-service.ts
+++ b/src/lib/features/feature-toggle/feature-toggle-service.ts
@@ -101,7 +101,7 @@ import { IChangeRequestAccessReadModel } from '../change-request-access-service/
import { checkFeatureFlagNamesAgainstPattern } from '../feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
import { IDependentFeaturesReadModel } from '../dependent-features/dependent-features-read-model-type';
-import EventService from '../../services/event-service';
+import EventService from '../events/event-service';
import { DependentFeaturesService } from '../dependent-features/dependent-features-service';
import { FeatureToggleInsert } from './feature-toggle-store';
diff --git a/src/lib/features/maintenance/maintenance-service.test.ts b/src/lib/features/maintenance/maintenance-service.test.ts
index bbbaadb82eb7..7e7d7ae63b57 100644
--- a/src/lib/features/maintenance/maintenance-service.test.ts
+++ b/src/lib/features/maintenance/maintenance-service.test.ts
@@ -3,7 +3,7 @@ import MaintenanceService from './maintenance-service';
import SettingService from '../../services/setting-service';
import { createTestConfig } from '../../../test/config/test-config';
import FakeSettingStore from '../../../test/fixtures/fake-setting-store';
-import EventService from '../../services/event-service';
+import EventService from '../events/event-service';
test('Scheduler should run scheduled functions if maintenance mode is off', async () => {
const config = createTestConfig();
diff --git a/src/lib/features/metrics/client-metrics/client-metrics.ts b/src/lib/features/metrics/client-metrics/client-metrics.ts
index 17db5aa48a42..14ab245b2754 100644
--- a/src/lib/features/metrics/client-metrics/client-metrics.ts
+++ b/src/lib/features/metrics/client-metrics/client-metrics.ts
@@ -39,7 +39,7 @@ class ClientMetricsController extends Controller {
private static HOURS_BACK_MAX = 48;
- private static HOURS_BACK_MAX_V2 = 24 * 31 * 3; // 3 months
+ private static HOURS_BACK_MAX_V2 = 24 * 91; // 91 days
constructor(
config: IUnleashConfig,
diff --git a/src/lib/features/project-environments/environment-service.ts b/src/lib/features/project-environments/environment-service.ts
index bf2e77c129b0..028568dfbf5f 100644
--- a/src/lib/features/project-environments/environment-service.ts
+++ b/src/lib/features/project-environments/environment-service.ts
@@ -21,7 +21,7 @@ import { IProjectStore } from '../../types/stores/project-store';
import MinimumOneEnvironmentError from '../../error/minimum-one-environment-error';
import { IFlagResolver } from '../../types/experimental';
import { CreateFeatureStrategySchema } from '../../openapi';
-import EventService from '../../services/event-service';
+import EventService from '../events/event-service';
export default class EnvironmentService {
private logger: Logger;
diff --git a/src/lib/features/project/createProjectService.ts b/src/lib/features/project/createProjectService.ts
index a34145314c11..1361c80a69f1 100644
--- a/src/lib/features/project/createProjectService.ts
+++ b/src/lib/features/project/createProjectService.ts
@@ -1,5 +1,5 @@
import { Db, IUnleashConfig } from '../../server-impl';
-import EventStore from '../../db/event-store';
+import EventStore from '../events/event-store';
import GroupStore from '../../db/group-store';
import { AccountStore } from '../../db/account-store';
import EnvironmentStore from '../project-environments/environment-store';
diff --git a/src/lib/features/scheduler/scheduler-service.test.ts b/src/lib/features/scheduler/scheduler-service.test.ts
index 8c8f71c8402c..adc8b5822524 100644
--- a/src/lib/features/scheduler/scheduler-service.test.ts
+++ b/src/lib/features/scheduler/scheduler-service.test.ts
@@ -4,7 +4,7 @@ import MaintenanceService from '../maintenance/maintenance-service';
import { createTestConfig } from '../../../test/config/test-config';
import SettingService from '../../services/setting-service';
import FakeSettingStore from '../../../test/fixtures/fake-setting-store';
-import EventService from '../../services/event-service';
+import EventService from '../events/event-service';
import { SCHEDULER_JOB_TIME } from '../../metric-events';
import EventEmitter from 'events';
@@ -176,10 +176,15 @@ test('Can handle crash of a async job', async () => {
await ms(75);
schedulerService.stop();
- expect(getRecords()).toEqual([
- ['initial scheduled job failed | id: test-id-10 | async reason'],
- ['interval scheduled job failed | id: test-id-10 | async reason'],
- ]);
+ const records = getRecords();
+ expect(records[0][0]).toContain(
+ 'initial scheduled job failed | id: test-id-10',
+ );
+ expect(records[0][1]).toContain('async reason');
+ expect(records[1][0]).toContain(
+ 'interval scheduled job failed | id: test-id-10',
+ );
+ expect(records[1][1]).toContain('async reason');
});
test('Can handle crash of a sync job', async () => {
@@ -196,30 +201,14 @@ test('Can handle crash of a sync job', async () => {
await ms(75);
schedulerService.stop();
- expect(getRecords()).toEqual([
- ['initial scheduled job failed | id: test-id-11 | Error: sync reason'],
- ['interval scheduled job failed | id: test-id-11 | Error: sync reason'],
- ]);
-});
-
-test('Can handle crash of a async job', async () => {
- const { logger, getRecords } = getLogger();
- const { schedulerService } = createSchedulerTestService({
- loggerOverride: logger,
- });
-
- const job = async () => {
- await Promise.reject('async reason');
- };
-
- await schedulerService.schedule(job, 50, 'test-id-10');
- await ms(75);
-
- schedulerService.stop();
- expect(getRecords()).toEqual([
- ['initial scheduled job failed | id: test-id-10 | async reason'],
- ['interval scheduled job failed | id: test-id-10 | async reason'],
- ]);
+ const records = getRecords();
+ expect(records[0][0]).toContain(
+ 'initial scheduled job failed | id: test-id-11',
+ );
+ expect(records[0][1].message).toContain('sync reason');
+ expect(records[1][0]).toContain(
+ 'interval scheduled job failed | id: test-id-11',
+ );
});
it('should emit scheduler job time event when scheduled function is run', async () => {
diff --git a/src/lib/features/scheduler/scheduler-service.ts b/src/lib/features/scheduler/scheduler-service.ts
index 1a6b8dc3444f..9852d6d17e05 100644
--- a/src/lib/features/scheduler/scheduler-service.ts
+++ b/src/lib/features/scheduler/scheduler-service.ts
@@ -52,7 +52,8 @@ export class SchedulerService {
}
} catch (e) {
this.logger.error(
- `interval scheduled job failed | id: ${id} | ${e}`,
+ `interval scheduled job failed | id: ${id}`,
+ e,
);
}
}, timeMs).unref(),
@@ -64,9 +65,7 @@ export class SchedulerService {
await runScheduledFunctionWithEvent();
}
} catch (e) {
- this.logger.error(
- `initial scheduled job failed | id: ${id} | ${e}`,
- );
+ this.logger.error(`initial scheduled job failed | id: ${id}`, e);
}
}
diff --git a/src/lib/features/tag-type/tag-type-service.ts b/src/lib/features/tag-type/tag-type-service.ts
index b330620af8c4..7deb36d0f172 100644
--- a/src/lib/features/tag-type/tag-type-service.ts
+++ b/src/lib/features/tag-type/tag-type-service.ts
@@ -12,7 +12,7 @@ import {
import { Logger } from '../../logger';
import { ITagType, ITagTypeStore } from './tag-type-store-type';
import { IUnleashConfig } from '../../types/option';
-import EventService from '../../services/event-service';
+import EventService from '../events/event-service';
import { SYSTEM_USER } from '../../types';
export default class TagTypeService {
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts
index 115e09acbd23..32df758e8d8d 100644
--- a/src/lib/metrics.ts
+++ b/src/lib/metrics.ts
@@ -456,10 +456,14 @@ export default class MetricsMonitor {
},
);
eventStore.on(FEATURE_ARCHIVED, ({ featureName, project }) => {
- featureToggleUpdateTotal.labels(featureName, project, 'n/a').inc();
+ featureToggleUpdateTotal
+ .labels(featureName, project, 'n/a', 'n/a')
+ .inc();
});
eventStore.on(FEATURE_REVIVED, ({ featureName, project }) => {
- featureToggleUpdateTotal.labels(featureName, project, 'n/a').inc();
+ featureToggleUpdateTotal
+ .labels(featureName, project, 'n/a', 'n/a')
+ .inc();
});
eventBus.on(CLIENT_METRICS, (m: ValidatedClientMetrics) => {
diff --git a/src/lib/middleware/pat-middleware.test.ts b/src/lib/middleware/pat-middleware.test.ts
index b23c71b4704f..d6167f7a5545 100644
--- a/src/lib/middleware/pat-middleware.test.ts
+++ b/src/lib/middleware/pat-middleware.test.ts
@@ -2,6 +2,7 @@ import getLogger from '../../test/fixtures/no-logger';
import patMiddleware from './pat-middleware';
import User from '../types/user';
import NotFoundError from '../error/notfound-error';
+import { AccountService } from '../services/account-service';
let config: any;
@@ -15,10 +16,11 @@ beforeEach(() => {
});
test('should not set user if unknown token', async () => {
+ // @ts-expect-error wrong type
const accountService = {
getAccountByPersonalAccessToken: jest.fn(),
addPATSeen: jest.fn(),
- };
+ } as AccountService;
const func = patMiddleware(config, { accountService });
@@ -37,9 +39,10 @@ test('should not set user if unknown token', async () => {
});
test('should not set user if token wrong format', async () => {
+ // @ts-expect-error wrong type
const accountService = {
getAccountByPersonalAccessToken: jest.fn(),
- };
+ } as AccountService;
const func = patMiddleware(config, { accountService });
@@ -65,10 +68,11 @@ test('should add user if known token', async () => {
id: 44,
username: 'my-user',
});
+ // @ts-expect-error wrong type
const accountService = {
getAccountByPersonalAccessToken: jest.fn().mockReturnValue(apiUser),
addPATSeen: jest.fn(),
- };
+ } as AccountService;
const func = patMiddleware(config, { accountService });
@@ -89,11 +93,12 @@ test('should add user if known token', async () => {
test('should call next if accountService throws exception', async () => {
getLogger.setMuteError(true);
+ // @ts-expect-error wrong types
const accountService = {
getAccountByPersonalAccessToken: () => {
throw new Error('Error occurred');
},
- };
+ } as AccountService;
const func = patMiddleware(config, { accountService });
@@ -126,11 +131,12 @@ test('Should not log at error level if user not found', async () => {
isEnabled: jest.fn().mockReturnValue(true),
},
};
+ // @ts-expect-error wrong type
const accountService = {
getAccountByPersonalAccessToken: jest.fn().mockImplementation(() => {
throw new NotFoundError('Could not find pat');
}),
- };
+ } as AccountService;
const mw = patMiddleware(conf, { accountService });
const cb = jest.fn();
diff --git a/src/lib/middleware/pat-middleware.ts b/src/lib/middleware/pat-middleware.ts
index a3ffc520dadf..519daae9c429 100644
--- a/src/lib/middleware/pat-middleware.ts
+++ b/src/lib/middleware/pat-middleware.ts
@@ -1,11 +1,11 @@
import { IUnleashConfig } from '../types';
import { IAuthRequest } from '../routes/unleash-types';
import NotFoundError from '../error/notfound-error';
+import { AccountService } from '../services/account-service';
-/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const patMiddleware = (
{ getLogger }: Pick,
- { accountService }: any,
+ { accountService }: { accountService: AccountService },
): any => {
const logger = getLogger('/middleware/pat-middleware.ts');
logger.debug('Enabling PAT middleware');
diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts
index 107f54def639..60f8052e853d 100644
--- a/src/lib/routes/admin-api/event.ts
+++ b/src/lib/routes/admin-api/event.ts
@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
-import EventService from '../../services/event-service';
+import EventService from '../../features/events/event-service';
import { ADMIN, NONE } from '../../types/permissions';
import { IEvent, IEventList } from '../../types/events';
import Controller from '../controller';
diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts
index 5252857b9b1e..2c5d689c6286 100644
--- a/src/lib/services/access-service.ts
+++ b/src/lib/services/access-service.ts
@@ -48,7 +48,7 @@ import {
ROLE_UPDATED,
SYSTEM_USER,
} from '../types';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
const { ADMIN } = permissions;
diff --git a/src/lib/services/addon-service.test.ts b/src/lib/services/addon-service.test.ts
index 535081985ee7..e13b2a696665 100644
--- a/src/lib/services/addon-service.test.ts
+++ b/src/lib/services/addon-service.test.ts
@@ -14,7 +14,7 @@ import AddonService from './addon-service';
import { IAddonDto } from '../types/stores/addon-store';
import SimpleAddon from './addon-service-test-simple-addon';
import { IAddonProviders } from '../addons';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import { SYSTEM_USER } from '../types';
const MASKED_VALUE = '*****';
diff --git a/src/lib/services/addon-service.ts b/src/lib/services/addon-service.ts
index c063e43f8fc4..648cf341faf7 100644
--- a/src/lib/services/addon-service.ts
+++ b/src/lib/services/addon-service.ts
@@ -11,7 +11,7 @@ import { IAddon, IAddonDto, IAddonStore } from '../types/stores/addon-store';
import { IUnleashStores, IUnleashConfig, SYSTEM_USER } from '../types';
import { IAddonDefinition } from '../types/model';
import { minutesToMilliseconds } from 'date-fns';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import { omitKeys } from '../util';
const SUPPORTED_EVENTS = Object.keys(events).map((k) => events[k]);
diff --git a/src/lib/services/api-token-service.test.ts b/src/lib/services/api-token-service.test.ts
index 25aad84b7d4c..da63439d4620 100644
--- a/src/lib/services/api-token-service.test.ts
+++ b/src/lib/services/api-token-service.test.ts
@@ -12,7 +12,7 @@ import {
API_TOKEN_UPDATED,
} from '../types';
import { addDays } from 'date-fns';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
import { createFakeEventsService } from '../../lib/features';
diff --git a/src/lib/services/api-token-service.ts b/src/lib/services/api-token-service.ts
index 49eef45f5419..3acf9c20a783 100644
--- a/src/lib/services/api-token-service.ts
+++ b/src/lib/services/api-token-service.ts
@@ -33,7 +33,7 @@ import {
extractUsernameFromUser,
omitKeys,
} from '../util';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
const resolveTokenPermissions = (tokenType: string) => {
if (tokenType === ApiTokenType.ADMIN) {
diff --git a/src/lib/services/context-service.ts b/src/lib/services/context-service.ts
index 554cd94869b7..73bc807e938d 100644
--- a/src/lib/services/context-service.ts
+++ b/src/lib/services/context-service.ts
@@ -10,7 +10,7 @@ import { IUnleashConfig } from '../types/option';
import { ContextFieldStrategiesSchema } from '../openapi/spec/context-field-strategies-schema';
import { IFeatureStrategy, IFlagResolver } from '../types';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
const { contextSchema, nameSchema } = require('./context-schema');
const NameExistsError = require('../error/name-exists-error');
diff --git a/src/lib/services/event-service.ts b/src/lib/services/event-service.ts
index 01223b23e039..52608715df1d 100644
--- a/src/lib/services/event-service.ts
+++ b/src/lib/services/event-service.ts
@@ -1,141 +1,4 @@
-import { IUnleashConfig } from '../types/option';
-import { IFeatureTagStore, IUnleashStores } from '../types/stores';
-import { Logger } from '../logger';
-import { IEventStore } from '../types/stores/event-store';
-import { IBaseEvent, IEventList, IUserEvent } from '../types/events';
-import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
-import EventEmitter from 'events';
-import { ADMIN_TOKEN_USER, IApiUser, ITag, IUser, SYSTEM_USER } from '../types';
-import { ApiTokenType } from '../../lib/types/models/api-token';
-
-export default class EventService {
- private logger: Logger;
-
- private eventStore: IEventStore;
-
- private featureTagStore: IFeatureTagStore;
-
- constructor(
- {
- eventStore,
- featureTagStore,
- }: Pick,
- { getLogger }: Pick,
- ) {
- this.logger = getLogger('services/event-service.ts');
- this.eventStore = eventStore;
- this.featureTagStore = featureTagStore;
- }
-
- async getEvents(): Promise {
- const totalEvents = await this.eventStore.count();
- const events = await this.eventStore.getEvents();
- return {
- events,
- totalEvents,
- };
- }
-
- async searchEvents(search: SearchEventsSchema): Promise {
- const totalEvents = await this.eventStore.filteredCount(search);
- const events = await this.eventStore.searchEvents(search);
- return {
- events,
- totalEvents,
- };
- }
-
- async onEvent(
- eventName: string | symbol,
- listener: (...args: any[]) => void,
- ): Promise {
- return this.eventStore.on(eventName, listener);
- }
-
- private async enhanceEventsWithTags(
- events: IBaseEvent[],
- ): Promise {
- const featureNamesSet = new Set();
- for (const event of events) {
- if (event.featureName && !event.tags) {
- featureNamesSet.add(event.featureName);
- }
- }
-
- const featureTagsMap: Map = new Map();
- const allTagsInFeatures = await this.featureTagStore.getAllByFeatures(
- Array.from(featureNamesSet),
- );
-
- for (const tag of allTagsInFeatures) {
- const featureTags = featureTagsMap.get(tag.featureName) || [];
- featureTags.push({ value: tag.tagValue, type: tag.tagType });
- featureTagsMap.set(tag.featureName, featureTags);
- }
-
- for (const event of events) {
- if (event.featureName && !event.tags) {
- event.tags = featureTagsMap.get(event.featureName);
- }
- }
-
- return events;
- }
-
- isAdminToken(user: IUser | IApiUser): boolean {
- return (user as IApiUser)?.type === ApiTokenType.ADMIN;
- }
-
- getUserDetails(user: IUser | IApiUser): {
- createdBy: string;
- createdByUserId: number;
- } {
- return {
- createdBy:
- (user as IUser)?.email ||
- user?.username ||
- (this.isAdminToken(user)
- ? ADMIN_TOKEN_USER.username
- : SYSTEM_USER.username),
- createdByUserId:
- (user as IUser)?.id ||
- (user as IApiUser)?.internalAdminTokenUserId ||
- SYSTEM_USER.id,
- };
- }
-
- /**
- * @deprecated use storeUserEvent instead
- */
- async storeEvent(event: IBaseEvent): Promise {
- return this.storeEvents([event]);
- }
-
- /**
- * @deprecated use storeUserEvents instead
- */
- async storeEvents(events: IBaseEvent[]): Promise {
- let enhancedEvents = events;
- for (const enhancer of [this.enhanceEventsWithTags.bind(this)]) {
- enhancedEvents = await enhancer(enhancedEvents);
- }
- return this.eventStore.batchStore(enhancedEvents);
- }
-
- async storeUserEvent(event: IUserEvent): Promise {
- return this.storeUserEvents([event]);
- }
-
- async storeUserEvents(events: IUserEvent[]): Promise {
- let enhancedEvents = events.map(({ byUser, ...event }) => {
- return {
- ...event,
- ...this.getUserDetails(byUser),
- };
- });
- for (const enhancer of [this.enhanceEventsWithTags.bind(this)]) {
- enhancedEvents = await enhancer(enhancedEvents);
- }
- return this.eventStore.batchStore(enhancedEvents);
- }
-}
+import EventService from '../features/events/event-service';
+// For backward compatibility
+export * from '../features/events/event-service';
+export default EventService;
diff --git a/src/lib/services/favorites-service.ts b/src/lib/services/favorites-service.ts
index 515038bd356f..e859e72fed8f 100644
--- a/src/lib/services/favorites-service.ts
+++ b/src/lib/services/favorites-service.ts
@@ -12,7 +12,7 @@ import {
import { IUser } from '../types/user';
import { extractUsernameFromUser } from '../util';
import { IFavoriteProjectKey } from '../types/stores/favorite-projects';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export interface IFavoriteFeatureProps {
feature: string;
diff --git a/src/lib/services/feature-service-potentially-stale.test.ts b/src/lib/services/feature-service-potentially-stale.test.ts
index 1db2214a830c..eb4289e1f6e2 100644
--- a/src/lib/services/feature-service-potentially-stale.test.ts
+++ b/src/lib/services/feature-service-potentially-stale.test.ts
@@ -11,7 +11,7 @@ import { IChangeRequestAccessReadModel } from '../features/change-request-access
import { ISegmentService } from '../segments/segment-service-interface';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
import { IDependentFeaturesReadModel } from '../features/dependent-features/dependent-features-read-model-type';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
import { DependentFeaturesService } from '../features/dependent-features/dependent-features-service';
diff --git a/src/lib/services/feature-tag-service.ts b/src/lib/services/feature-tag-service.ts
index 8206d0dc9477..672b51d7b6cd 100644
--- a/src/lib/services/feature-tag-service.ts
+++ b/src/lib/services/feature-tag-service.ts
@@ -12,7 +12,7 @@ import {
import { ITagStore } from '../types/stores/tag-store';
import { ITag } from '../types/model';
import { BadDataError, FOREIGN_KEY_VIOLATION } from '../../lib/error';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
class FeatureTagService {
private tagStore: ITagStore;
diff --git a/src/lib/services/feature-type-service.ts b/src/lib/services/feature-type-service.ts
index 20309de0f747..b3546f1f9582 100644
--- a/src/lib/services/feature-type-service.ts
+++ b/src/lib/services/feature-type-service.ts
@@ -6,7 +6,7 @@ import {
IFeatureTypeStore,
} from '../types/stores/feature-type-store';
import NotFoundError from '../error/notfound-error';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import { FEATURE_TYPE_UPDATED, IUser } from '../types';
import { extractUsernameFromUser } from '../util';
diff --git a/src/lib/services/group-service.ts b/src/lib/services/group-service.ts
index 26920c7bd996..df8df4d97e1c 100644
--- a/src/lib/services/group-service.ts
+++ b/src/lib/services/group-service.ts
@@ -22,7 +22,7 @@ import {
import NameExistsError from '../error/name-exists-error';
import { IAccountStore } from '../types/stores/account-store';
import { IUser } from '../types/user';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export class GroupService {
private groupStore: IGroupStore;
diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts
index acb5b45c2ad3..b05503453e38 100644
--- a/src/lib/services/index.ts
+++ b/src/lib/services/index.ts
@@ -1,6 +1,6 @@
import { IUnleashConfig, IUnleashStores, IUnleashServices } from '../types';
import FeatureTypeService from './feature-type-service';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import HealthService from './health-service';
import ProjectService from './project-service';
diff --git a/src/lib/services/pat-service.ts b/src/lib/services/pat-service.ts
index 3bdcb108d0e0..825ac34f8458 100644
--- a/src/lib/services/pat-service.ts
+++ b/src/lib/services/pat-service.ts
@@ -9,7 +9,7 @@ import BadDataError from '../error/bad-data-error';
import NameExistsError from '../error/name-exists-error';
import { OperationDeniedError } from '../error/operation-denied-error';
import { PAT_LIMIT } from '../util/constants';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export default class PatService {
private config: IUnleashConfig;
diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts
index 43c856be5f70..c8eb50f5e9af 100644
--- a/src/lib/services/project-service.ts
+++ b/src/lib/services/project-service.ts
@@ -66,7 +66,7 @@ import { BadDataError, PermissionError } from '../error';
import { ProjectDoraMetricsSchema } from '../openapi';
import { checkFeatureNamingData } from '../features/feature-naming-pattern/feature-naming-validation';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
const getCreatedBy = (user: IUser) => user.email || user.username || 'unknown';
diff --git a/src/lib/services/public-signup-token-service.ts b/src/lib/services/public-signup-token-service.ts
index 947578fedd97..31403c5c5d97 100644
--- a/src/lib/services/public-signup-token-service.ts
+++ b/src/lib/services/public-signup-token-service.ts
@@ -17,7 +17,7 @@ import UserService from './user-service';
import { IUser } from '../types/user';
import { URL } from 'url';
import { add } from 'date-fns';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export class PublicSignupTokenService {
private store: IPublicSignupTokenStore;
diff --git a/src/lib/services/scheduler-service.test.ts b/src/lib/services/scheduler-service.test.ts
index 7321b0d4dd1e..88b0d4287166 100644
--- a/src/lib/services/scheduler-service.test.ts
+++ b/src/lib/services/scheduler-service.test.ts
@@ -3,7 +3,7 @@ import { SchedulerService } from '../features/scheduler/scheduler-service';
import { createTestConfig } from '../../test/config/test-config';
import FakeSettingStore from '../../test/fixtures/fake-setting-store';
import SettingService from './setting-service';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import MaintenanceService from '../features/maintenance/maintenance-service';
function ms(timeMs: number) {
@@ -100,10 +100,13 @@ test('Can handle crash of a async job', async () => {
await ms(75);
schedulerService.stop();
- expect(getRecords()).toEqual([
- ['initial scheduled job failed | id: test-id-10 | async reason'],
- ['interval scheduled job failed | id: test-id-10 | async reason'],
- ]);
+ const records = getRecords();
+ expect(records[0][0]).toContain(
+ 'initial scheduled job failed | id: test-id-10',
+ );
+ expect(records[1][0]).toContain(
+ 'interval scheduled job failed | id: test-id-10',
+ );
});
test('Can handle crash of a sync job', async () => {
@@ -115,8 +118,11 @@ test('Can handle crash of a sync job', async () => {
await ms(75);
schedulerService.stop();
- expect(getRecords()).toEqual([
- ['initial scheduled job failed | id: test-id-11 | Error: sync reason'],
- ['interval scheduled job failed | id: test-id-11 | Error: sync reason'],
- ]);
+ const records = getRecords();
+ expect(records[0][0]).toContain(
+ 'initial scheduled job failed | id: test-id-11',
+ );
+ expect(records[1][0]).toContain(
+ 'interval scheduled job failed | id: test-id-11',
+ );
});
diff --git a/src/lib/services/segment-service.ts b/src/lib/services/segment-service.ts
index ccd8b822332b..ad600652d9f8 100644
--- a/src/lib/services/segment-service.ts
+++ b/src/lib/services/segment-service.ts
@@ -26,7 +26,7 @@ import {
import { PermissionError } from '../error';
import { IChangeRequestAccessReadModel } from '../features/change-request-access-service/change-request-access-read-model';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import { IChangeRequestSegmentUsageReadModel } from '../features/change-request-segment-usage-service/change-request-segment-usage-read-model';
export class SegmentService implements ISegmentService {
diff --git a/src/lib/services/setting-service.ts b/src/lib/services/setting-service.ts
index 6cc45c4c61f0..72da01e23415 100644
--- a/src/lib/services/setting-service.ts
+++ b/src/lib/services/setting-service.ts
@@ -7,7 +7,7 @@ import {
SettingDeletedEvent,
SettingUpdatedEvent,
} from '../types/events';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export default class SettingService {
private config: IUnleashConfig;
diff --git a/src/lib/services/state-service.test.ts b/src/lib/services/state-service.test.ts
index 4799cc00b69a..839b6ae8bfba 100644
--- a/src/lib/services/state-service.test.ts
+++ b/src/lib/services/state-service.test.ts
@@ -13,7 +13,7 @@ import {
} from '../types/events';
import { GLOBAL_ENV } from '../types/environment';
import variantsExportV3 from '../../test/examples/variantsexport_v3.json';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import { SYSTEM_USER_ID } from '../types';
const oldExportExample = require('./state-service-export-v1.json');
const TESTUSERID = 3333;
diff --git a/src/lib/services/state-service.ts b/src/lib/services/state-service.ts
index a03d78559c8b..9a099ec40585 100644
--- a/src/lib/services/state-service.ts
+++ b/src/lib/services/state-service.ts
@@ -52,7 +52,7 @@ import { DEFAULT_ENV } from '../util/constants';
import { GLOBAL_ENV } from '../types/environment';
import { ISegmentStore } from '../types/stores/segment-store';
import { PartialSome } from '../types/partial';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export interface IBackupOption {
includeFeatureToggles: boolean;
diff --git a/src/lib/services/strategy-service.ts b/src/lib/services/strategy-service.ts
index 20d9b67871d1..4223af5530e8 100644
--- a/src/lib/services/strategy-service.ts
+++ b/src/lib/services/strategy-service.ts
@@ -7,7 +7,7 @@ import {
IStrategyStore,
} from '../types/stores/strategy-store';
import NotFoundError from '../error/notfound-error';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
const strategySchema = require('./strategy-schema');
const NameExistsError = require('../error/name-exists-error');
diff --git a/src/lib/services/tag-service.ts b/src/lib/services/tag-service.ts
index e7165efcb65d..a55d14827c3b 100644
--- a/src/lib/services/tag-service.ts
+++ b/src/lib/services/tag-service.ts
@@ -6,7 +6,7 @@ import { IUnleashStores } from '../types/stores';
import { IUnleashConfig } from '../types/option';
import { ITagStore } from '../types/stores/tag-store';
import { ITag } from '../types/model';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
export default class TagService {
private tagStore: ITagStore;
diff --git a/src/lib/services/user-service.test.ts b/src/lib/services/user-service.test.ts
index 9eeb514aa32f..e1451fdb9eef 100644
--- a/src/lib/services/user-service.test.ts
+++ b/src/lib/services/user-service.test.ts
@@ -14,7 +14,7 @@ import User from '../types/user';
import FakeResetTokenStore from '../../test/fixtures/fake-reset-token-store';
import SettingService from './setting-service';
import FakeSettingStore from '../../test/fixtures/fake-setting-store';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
const config: IUnleashConfig = createTestConfig();
diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts
index 8044df29ff8d..60d83cca8bd4 100644
--- a/src/lib/services/user-service.ts
+++ b/src/lib/services/user-service.ts
@@ -31,7 +31,7 @@ import BadDataError from '../error/bad-data-error';
import { isDefined } from '../util/isDefined';
import { TokenUserSchema } from '../openapi/spec/token-user-schema';
import PasswordMismatch from '../error/password-mismatch';
-import EventService from './event-service';
+import EventService from '../features/events/event-service';
const systemUser = new User({ id: -1, username: 'system' });
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index dc82cd2d1c45..af46ae2ffb39 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -44,7 +44,8 @@ export type IFlagKey =
| 'extendedUsageMetrics'
| 'extendedUsageMetricsUI'
| 'adminTokenKillSwitch'
- | 'changeRequestConflictHandling';
+ | 'changeRequestConflictHandling'
+ | 'executiveDashboard';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@@ -203,6 +204,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_CHANGE_REQUEST_CONFLICT_HANDLING,
false,
),
+ executiveDashboard: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_EXECUTIVE_DASHBOARD,
+ false,
+ ),
};
export const defaultExperimentalOptions: IExperimentalOptions = {
diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts
index 49db78eb6654..e9b02e92971e 100644
--- a/src/lib/types/permissions.ts
+++ b/src/lib/types/permissions.ts
@@ -65,6 +65,14 @@ export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
export const SKIP_CHANGE_REQUEST = 'SKIP_CHANGE_REQUEST';
+export const PROJECT_USER_ACCESS_READ = 'PROJECT_USER_ACCESS_READ';
+export const PROJECT_DEFAULT_STRATEGY_READ = 'PROJECT_DEFAULT_STRATEGY_READ';
+export const PROJECT_CHANGE_REQUEST_READ = 'PROJECT_CHANGE_REQUEST_READ';
+export const PROJECT_SETTINGS_READ = 'PROJECT_SETTINGS_READ';
+export const PROJECT_USER_ACCESS_WRITE = 'PROJECT_USER_ACCESS_WRITE';
+export const PROJECT_DEFAULT_STRATEGY_WRITE = 'PROJECT_DEFAULT_STRATEGY_WRITE';
+export const PROJECT_CHANGE_REQUEST_WRITE = 'PROJECT_CHANGE_REQUEST_WRITE';
+export const PROJECT_SETTINGS_WRITE = 'PROJECT_SETTINGS_WRITE';
export const ROOT_PERMISSION_CATEGORIES = [
{
label: 'Addon',
diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts
index 8305cb5a1279..e4fbbb26ea1b 100644
--- a/src/lib/types/services.ts
+++ b/src/lib/types/services.ts
@@ -13,7 +13,7 @@ import { EmailService } from '../services/email-service';
import UserService from '../services/user-service';
import ResetTokenService from '../services/reset-token-service';
import FeatureTypeService from '../services/feature-type-service';
-import EventService from '../services/event-service';
+import EventService from '../features/events/event-service';
import HealthService from '../services/health-service';
import SettingService from '../services/setting-service';
import SessionService from '../services/session-service';
diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts
index d18aa6c2bc91..235d5600bc2b 100644
--- a/src/lib/types/stores/event-store.ts
+++ b/src/lib/types/stores/event-store.ts
@@ -2,7 +2,7 @@ import { IBaseEvent, IEvent } from '../events';
import { Store } from './store';
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
import EventEmitter from 'events';
-import { IQueryOperations } from '../../db/event-store';
+import { IQueryOperations } from '../../features/events/event-store';
export interface IEventStore
extends Store,
diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts
index bb00b4416ba7..af28acb4cb8e 100644
--- a/src/lib/util/constants.ts
+++ b/src/lib/util/constants.ts
@@ -3,6 +3,8 @@ export const DEFAULT_ENV = 'default';
export const ALL_PROJECTS = '*';
export const ALL_ENVS = '*';
+export const URL_SAFE_BASIC = /^[a-z0-9\-_]*$/;
+
export const ROOT_PERMISSION_TYPE = 'root';
export const ENVIRONMENT_PERMISSION_TYPE = 'environment';
export const PROJECT_PERMISSION_TYPE = 'project';
diff --git a/src/migrations/20240117093601-add-more-granular-project-permissions.js b/src/migrations/20240117093601-add-more-granular-project-permissions.js
new file mode 100644
index 000000000000..05d2c5eca382
--- /dev/null
+++ b/src/migrations/20240117093601-add-more-granular-project-permissions.js
@@ -0,0 +1,26 @@
+exports.up = function(db, cb) {
+ db.runSql(`
+ INSERT INTO permissions(permission, display_name, type) VALUES
+ ('PROJECT_USER_ACCESS_READ', 'View only access to Project User Access', 'project'),
+ ('PROJECT_DEFAULT_STRATEGY_READ', 'View only access to default strategy configuration for project', 'project'),
+ ('PROJECT_CHANGE_REQUEST_READ', 'View only access to change request configuration for project', 'project'),
+ ('PROJECT_SETTINGS_READ', 'View only access to project settings', 'project'),
+ ('PROJECT_USER_ACCESS_WRITE', 'Write access to Project User Access', 'project'),
+ ('PROJECT_DEFAULT_STRATEGY_WRITE', 'Write access to default strategy configuration for project', 'project'),
+ ('PROJECT_CHANGE_REQUEST_WRITE', 'Write access to change request configuration for project', 'project'),
+ ('PROJECT_SETTINGS_WRITE', 'Write access to project settings', 'project');
+ `, cb);
+};
+
+exports.down = function(db, cb) {
+ db.runSql(`
+ DELETE FROM permissions WHERE permission IN ('PROJECT_USER_ACCESS_READ',
+ 'PROJECT_DEFAULT_STRATEGY_READ',
+ 'PROJECT_CHANGE_REQUEST_READ',
+ 'PROJECT_SETTINGS_READ',
+ 'PROJECT_USER_ACCESS_WRITE',
+ 'PROJECT_DEFAULT_STRATEGY_WRITE',
+ 'PROJECT_CHANGE_REQUEST_WRITE',
+ 'PROJECT_SETTINGS_WRITE');
+ `, cb);
+};
diff --git a/src/migrations/20240118093611-missing-primary-keys.js b/src/migrations/20240118093611-missing-primary-keys.js
new file mode 100644
index 000000000000..e6ba1ccd44d9
--- /dev/null
+++ b/src/migrations/20240118093611-missing-primary-keys.js
@@ -0,0 +1,24 @@
+'use strict';
+
+exports.up = function (db, callback) {
+ db.runSql(
+ `
+ ALTER TABLE project_stats ADD PRIMARY KEY (project);
+ ALTER TABLE api_token_project ADD PRIMARY KEY (secret, project);
+ ALTER TABLE role_permission ADD COLUMN id SERIAL PRIMARY KEY;
+ `,
+ callback,
+ );
+};
+
+exports.down = function (db, callback) {
+ db.runSql(
+ `
+ ALTER TABLE project_stats DROP CONSTRAINT project_stats_pkey;
+ ALTER TABLE api_token_project DROP CONSTRAINT api_token_project_pkey;
+ ALTER TABLE role_permission DROP CONSTRAINT role_permission_pkey;
+ ALTER TABLE role_permission DROP COLUMN id;
+ `,
+ callback,
+ );
+};
diff --git a/src/server-dev.ts b/src/server-dev.ts
index c4ed2e4a4bde..9625c4b9d913 100644
--- a/src/server-dev.ts
+++ b/src/server-dev.ts
@@ -50,6 +50,7 @@ process.nextTick(async () => {
featureSearchFeedback: true,
newStrategyConfigurationFeedback: true,
extendedUsageMetricsUI: true,
+ executiveDashboard: true,
},
},
authentication: {
diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts
index 6e8c4720c268..e625c2c211e7 100644
--- a/src/test/fixtures/fake-event-store.ts
+++ b/src/test/fixtures/fake-event-store.ts
@@ -1,7 +1,7 @@
import { IEventStore } from '../../lib/types/stores/event-store';
import { IEvent } from '../../lib/types/events';
import { sharedEventEmitter } from '../../lib/util/anyEventEmitter';
-import { IQueryOperations } from '../../lib/db/event-store';
+import { IQueryOperations } from '../../lib/features/events/event-store';
import { SearchEventsSchema } from '../../lib/openapi';
import EventEmitter from 'events';
diff --git a/website/docs/feature-flag-tutorials/react/examples.md b/website/docs/feature-flag-tutorials/react/examples.md
new file mode 100644
index 000000000000..587baf38099d
--- /dev/null
+++ b/website/docs/feature-flag-tutorials/react/examples.md
@@ -0,0 +1,627 @@
+---
+title: React Feature Flag Examples
+slug: /feature-flag-tutorials/react/examples
+---
+
+# React Feature Flag Examples
+
+In our [React tutorial](/feature-flag-tutorials/react), we implemented a simple on/off feature flag that could turned on and off. In the real world, many feature flag use cases have more nuance than this. This document will walk you through some common examples of using feature flags in React with some of those more advanced use cases in mind.
+
+Applications evolve, and teams must manage all aspects of this evolution, including the flags used to control the application. We built multiple features into Unleash to address the complexities of releasing code and managing feature flags along the way:
+
+
+1. [Gradual rollouts](#gradual-rollouts-for-react-apps)
+2. [Canary deployments](#canary-deployments-in-react)
+3. [A/B testing](#ab-testing-in-react)
+4. [Feature flag metrics & reporting](#feature-flag-analytics-and-reporting-in-react)
+5. [Feature flag audit logs](#feature-flag-audit-logs-in-react)
+6. [Change management & feature flag approvals](#change-management--feature-flag-approvals-in-react)
+7. [Flag automation & workflow integration](#flag-automation--workflow-integration-for-react-apps)
+8. [Common usage examples of React feature flags](#common-usage-examples-of-react-feature-flags)
+
+
+## Gradual Rollouts for React Apps
+
+
+It’s common to use feature flags to roll out changes to a percentage of users. Flags allow you to monitor your application and infrastructure for undesired behavior (such as errors, memory bottlenecks, etc.) and to see if the changes improve the outcomes for your application (to increase sales, reduce support requests, etc.)
+
+Doing a gradual rollout for a React-based application with Unleash is very straightforward. To see this in action, follow our [How to Implement Feature Flags in React](/feature-flag-tutorials/react) tutorial, which implements a notification feature using feature flags.
+
+Once you have completed the tutorial, you can modify the basic setup to adjust the percentage of users who experience this feature with a gradual rollout. The percentage of users split between the notification feature being visible or not is cached so their user experience will remain consistent.
+
+Navigate to the Gradual Rollout form in Unleash by using the "Edit strategy" button.
+
+
+![The "edit strategy" button uses a pencil icon and is located on every strategy.](/img/react-ex-grad-rollout-edit.png)
+
+
+Adjust the percentage of users to 50% or whichever value you choose, and refresh your app in the browser to see if your user has the new feature experience.
+
+
+![Gradual rollout form](/img/react-ex-grad-rollout-form.png)
+
+
+You can achieve this same result using our API with the following code:
+
+```
+curl --location --request PUT 'http://localhost:4242/api/admin/projects/default/features/newNotificationsBadge/environments/development/strategies/{STRATEGY_ID}' \
+ --header 'Authorization: INSERT_API_KEY' \
+ --header 'Content-Type: application/json' \
+ --data-raw '{
+ "name": "flexibleRollout",
+ "title": "",
+ "constraints": [],
+ "parameters": {
+ "rollout": "50",
+ "stickiness": "default",
+ "groupId": "newNotificationsBadge"
+ },
+ "variants": [],
+ "segments": [],
+ "disabled": false
+}'
+
+```
+
+Learn more about [gradual rollouts in our docs](/reference/activation-strategies.md).
+
+
+## Canary Deployments in React
+
+
+### What is a canary deployment?
+
+Canary releases are a way to test and release code in different environments for a subset of your audience, which determines which features or versions of the platform people have access to.
+
+
+### Why use canary deployments?
+
+Canary deployments are a safer and more gradual way to make changes in software development. They help find any abnormalities and align with the agile process for faster releases and quick reversions.
+
+
+### How to leverage feature flags for canary deployments in React?
+
+Unleash has a few ways to help manage canary deployments for React apps at scale:
+
+
+* Using a [gradual rollout](/reference/activation-strategies#gradual-rollout) (which we implemented in a previous section) would be a simple use case but would reduce the amount of control you have over who gets the new feature.
+
+* Using either [constraints](/reference/strategy-constraints) or [segments](/reference/segments) (which are a collection of constraints) for a subset of your users to get the new feature vs. the old feature, for _more_ control than a gradual rollout
+
+* [Strategy variants](/reference/strategy-variants) are used to do the same canary deployment, but can be scaled to more _advanced_ cases. For example, if you have 2+ new features and are testing to see if they are better than the old one, you can use variants to split your population of users and conduct an A/B test with them.
+
+Let’s walk through how to utilize strategy constraints in our React app through the Unleash platform.
+
+
+#### Configure strategy constraints for canary deployments
+
+We will build a strategy constraint on top of our existing gradual rollout strategy. This will allow us to target a subset of users to rollout to.
+
+In Unleash, start from the [feature flag view](http://localhost:4242/projects/default/features/newNotificationsBadge) and edit your Gradual Rollout strategy from your development environment.
+
+
+![Gradual Rollout configure strategy constraint image](/img/react-ex-grad-rollout-edit.png)
+
+
+This will take you to the gradual rollout form. Click on the ‘Add constraint’ button.
+
+
+![Add constraint button image](/img/react-ex-add-constraint-btn.png)
+
+
+Let’s say we are experimenting with releasing the notifications feature for a limited time. We want to release it to **all users**, capture some usage data to compare it to the old experience, and then automatically turn it off.
+
+We can configure the constraint in the form to match these requirements:
+
+
+![Create constraint image](/img/react-ex-constraint-form.png)
+
+
+* The context field is set to **currentTime**
+* The operator is set to **DATE_BEFORE**
+* The date time is set (to any time) in the future
+
+Once you’ve filled out the proper constraint fields, click ‘Done’ and save the strategy.
+
+
+![Graduall rollout with constraint image](/img/react-ex-rollout-with-constraint.png)
+
+
+Your release process is now configured with a datetime-based strategy constraint.
+
+Alternatively, you can send an API command to apply the same requirements:
+
+```
+curl --location --request PUT 'http://localhost:4242/api/admin/projects/default/features/newNotificationsBadge/environments/development/strategies/806ebcbd-bb03-4713-8081-7dca3905e612' \
+ --header 'Authorization: INSERT_API_KEY' \
+ --header 'Content-Type: application/json' \
+ --data-raw '{
+ "name": "flexibleRollout",
+ "title": "",
+ "constraints": [
+ {
+ "value": "2024-02-27T17:00:00.000Z",
+ "values": [],
+ "inverted": false,
+ "operator": "DATE_BEFORE",
+ "contextName": "currentTime",
+ "caseInsensitive": false
+ }
+ ],
+ "parameters": {
+ "rollout": "50",
+ "stickiness": "default",
+ "groupId": "newNotificationsBadge"
+ },
+ "variants": [],
+ "segments": [],
+ "disabled": false
+}'
+```
+
+Read our documentation for more context on the robustness of [strategy constraint configurations](/reference/strategy-constraints) and use cases.
+
+
+## A/B Testing in React
+
+
+A/B testing is a common way for teams to test out how users interact with two or more versions of a new feature that is released. At Unleash, we call these [variants](/reference/feature-toggle-variants).
+
+We can expose a particular version of the feature to select user bases when a flag is enabled. From there, a way to use the variants is to view the performance metrics and see which is more efficient.
+
+We can create several variations of this feature to release to users and gather performance metrics to determine which one yields better results. While teams may have different goals for measuring performance, Unleash enables you to configure strategy for the feature variants within your application/service and the platform.
+
+In the context of our [React tutorial](/feature-flag-tutorials/react), we have a notifications badge feature that displays in the top navigation menu. To implement feature flag variants for an A/B Test in React, we will set up a variant in the feature flag and use an announcement icon from Material UI to render a different version.
+
+In Unleash, navigate to the feature flag’s [Variants tab](http://localhost:4242/projects/default/features/newNotificationsBadge/variants) and click ‘Add variant’.
+
+
+![Add variant image](/img/react-ex-add-variant.png)
+
+
+In the Variants form, add two variants that will reference the different icons we will render in our app. Name them ‘notificationsIcon’ and ‘announcementsIcon’.
+
+Note: We won’t use any particular payload from these variants other than their default returned objects. For this example, we can keep the variant at 50% weight for each variant, meaning there is a 50% chance that a user will see one icon versus the other. You can adjust the percentages based on your needs, such as making one icon a majority of users would see by increasing its weight percentage. Learn more about [feature flag variant properties](/reference/feature-toggle-variants).
+
+
+![Variant form image](/img/react-ex-variant-form.png)
+
+
+Once you click 'Save variants' at the bottom of the form, your view will display the list of variants in the development environment with their respective metadata.
+
+
+![Variant added image](/img/react-ex-variant-added.png)
+
+
+Alternatively, we can create new variants via an API command below:
+
+```
+curl --location --request PATCH 'http://localhost:4242/api/admin/projects/default/features/newNotificationsBadge/environments/development/variants' \
+ --header 'Authorization: INSERT_API_KEY' \
+ --header 'Content-Type: application/json' \
+ --data-raw '[
+ {
+ "op": "replace",
+ "path": "/1/name",
+ "value": "announcementsIcon"
+ },
+ {
+ "op": "replace",
+ "path": "/0/name",
+ "value": "notificationsIcon"
+ }
+]'
+```
+
+Now that we have configured our feature flag variants, we can reference them in the React code.
+
+In `NavBar.tsx`, import the Announcements icon at the top of the file from Material UI, just like the Notifications icon.
+
+
+```js
+Announcement as AnnouncementsIcon,
+```
+
+
+The full import line now looks like this:
+
+
+```js
+import {
+ Menu as MenuIcon,
+ Notifications as NotificationsIcon,
+ Announcement as AnnouncementsIcon,
+ AttachMoney as AttachMoneyIcon,
+} from "@material-ui/icons";
+```
+
+
+Unleash has a built-in React SDK hook called [useVariant](/reference/sdks/react#check-variants) to retrieve variant data and perform different tasks against them.
+
+Next, import the `useVariant` hook from the React SDK:
+
+
+```js
+import { useFlag, useVariant } from "@unleash/proxy-client-react";
+```
+
+
+`useVariant` returns the name of the specific flag variant your user experiences, if it’s enabled, and if the overarching feature flag is enabled as well.
+
+Within the `NavBar` component itself, we will:
+
+* Reference the flag that holds the variants
+* Reference the two variants to conditionally check that their enabled status for rendering
+
+Next, we can modify what is returned in the `NavBar` component to account for the two variants the user might see on render.
+
+Previously, we toggled the `NotificationsIcon` component based on whether or not the feature flag was enabled. We’ll take it one step further and conditionally display the icon components based on the feature flag’s variant status:
+
+With our new variants implemented, 50% of users will see the announcements icon. The differences in the UI would look like this:
+
+
+![Compare icons in UI image](/img/react-ex-compare-icons.png)
+
+
+We have successfully configured our flag variants and implemented them into our React app for A/B testing in our development environment. Next, we can take a look at how Unleash can track the results of A/B testing and provide insights with data analytics.
+
+
+## Feature Flag Analytics and Reporting in React
+
+Shipping code is one thing, but monitoring your applications is another aspect of managing code that developers have to account for. Some things to consider would be:
+
+
+* Security concerns
+* Performance metrics
+* Tracking user behavior
+
+
+Unleash was built with all of these considerations in mind as part of our feature flag management approach. You can use feature flag events to send impression data to an analytics tool you choose to integrate. For example, a new feature you’ve released could be causing more autoscaling in your service resources than expected and you either can view that in your Analytics tool or get notified from a Slack integration. Our impression data gives developers a full view of the activity that could raise any alarms.
+
+We make it easy to get our data, your application, and an analytics tool connected so you can collect, analyze, and report relevant data for your teams.
+
+Let’s walk through how to enable impression data for the feature flag we created from the React tutorial and capture the data in our app for analytics usage.
+
+
+### Enable feature flag impression data
+
+At the flag level in Unleash, navigate to the [Settings view](http://localhost:4242/projects/default/features/newNotificationsBadge/settings).
+
+
+![Edit Settings image](/img/react-ex-edit-settings.png)
+
+
+In the Settings view, click on the edit button. This will take us to the ‘Edit Feature toggle’ form.
+
+
+![Enable impression data image](/img/react-ex-enable-impression-data.png)
+
+
+Turn on the impression data and hit ‘Save’. Events will now be emitted every time the feature flag is triggered.
+
+You can also use our API command to enable the impression data:
+
+```
+curl --location --request PATCH 'http://localhost:4242/api/admin/projects/default/features/newNotificationsBadge' \
+ --header 'Authorization: INSERT_API_KEY' \
+ --header 'Content-Type: application/json' \
+ --data-raw '[
+ {
+ "op": "replace",
+ "path": "/impressionData",
+ "value": true
+ }
+]'
+```
+
+### Capture impression data for flag analytics
+
+Next, let’s configure our React app to capture the impression events that are emitted when our flag is triggered. To do this, we can import the [useUnleashClient hook from the React SDK](https://github.com/Unleash/proxy-client-react/blob/main/src/useUnleashClient.ts) into our app. This hook allows us to interact directly with the Unleash client to listen for events and log them.
+
+In `NavBar.tsx`, pull in `useUnleashClient`. Our updated import line should look like this:
+
+```js
+import { useFlag, useVariant, useUnleashClient } from "@unleash/proxy-client-react";
+```
+
+Next, within the `NavBar` component itself, call `useUnleashClient` and wrap a `useEffect` hook around our function calls to grab impression data with this code snippet:
+
+```js
+ const unleashClient = useUnleashClient();
+
+ useEffect(() => {
+ unleashClient.start();
+
+ unleashClient.on("ready", () => {
+ const enabledImpression = unleashClient.isEnabled("newNotificationsBadge");
+ console.log(enabledImpression);
+ });
+
+ unleashClient.on("impression", (impressionEvent: object) => {
+ console.log(impressionEvent);
+ // Capture the event here and pass it internal data lake or analytics provider
+ });
+ }, [unleashClient]);
+```
+
+This code snippet starts the Unleash client, checks that our flag is enabled, and then stores impression events for your use.
+
+> **Note:** We are passing in unleashClient into the dependency array in useEffect to prevent the app from [unnecessarily mounting the component](https://react.dev/reference/react/useEffect#parameters) if the state of the data it holds has not changed.
+
+Our flag impression data is now being logged!
+
+In your browser, you can view the output for `isEnabled`:
+
+```js
+{
+ "eventType": "isEnabled",
+ "eventId": "b4455218-4a88-43aa-8712-b99186f46548",
+ "context": {
+ "sessionId": 386689528,
+ "appName": "cypress-realworld-app",
+ "environment": "default"
+ },
+ "enabled": true,
+ "featureName": "newNotificationsBadge",
+ "impressionData": true
+}
+```
+
+And the console.log for `getVariant` returns:
+
+```js
+{
+ "eventType": "getVariant",
+ "eventId": "c41aa58b-d2c7-45cf-b668-7267f465e01a",
+ "context": {
+ "sessionId": 386689528,
+ "appName": "cypress-realworld-app",
+ "environment": "default"
+ },
+ "enabled": true,
+ "featureName": "newNotificationsBadge",
+ "impressionData": true,
+ "variant": "announcementsIcon"
+}
+```
+
+You can find more information on `isEnabled` and `getVariant` in our [impression data docs](/reference/impression-data#impression-event-data).
+
+Now that the application is capturing impression events, you can configure the correct data fields and formatting to send to any analytics tool or data warehouse you use.
+
+
+### Application Metrics & Monitoring
+
+Under the [Metrics tab](http://localhost:4242/projects/default/features/newNotificationsBadge/metrics), you can see the general activity of the [Cypress Real World App from our React tutorial](/feature-flag-tutorials/react) in the development environment over different periods of time. If the app had a production environment enabled, we would also be able to view the amount of exposure and requests the app is receiving over time.
+
+
+![Metrics view image](/img/react-ex-metrics.png)
+
+
+Our metrics are great for understanding user traffic. You can get a better sense of:
+
+
+* What time(s) of the day or week are requests the highest?
+* Which feature flags are the most popular?
+
+
+Another use case for reviewing metrics is verifying that the right users are being exposed to your feature based on how you’ve configured your strategies and/or variants.
+
+Take a look at our [Metrics API documentation](/reference/api/unleash/metrics) to understand how it works from a code perspective.
+
+
+## Feature Flag Audit Logs in React
+
+Because a Feature Flag service controls the way an application behaves in production, it can be highly important to have visibility into when changes have been made and by whom. This is especially true in highly regulated environments, such as health care, insurance, banking, and others. In these cases (or similar), you might find audit logging useful for:
+
+
+1. Organizational compliance
+2. Change control
+
+Fortunately, this is straightforward in Unleash Enterprise.
+
+Unleash provides the data to log any change that has happened over time, at the flag level from a global level. In conjunction with Unleash, tools like [Splunk](https://www.splunk.com/) can help you combine logs and run advanced queries against them. Logs are useful for downstream data warehouses or data lakes.
+
+In our React tutorial application, we can [view Event logs](http://localhost:4242/projects/default/features/newNotificationsBadge/logs) to monitor the changes to flag strategies and statuses we have made throughout our examples, such as:
+
+
+* When the newNotificationsBadge flag was created
+* How the gradual rollout strategy was configured
+* When and how the variants were created and configured
+
+
+![Events log image](/img/react-ex-event-log.png)
+
+
+You can also retrieve event log data by using an API command below:
+
+```
+curl -L -X GET '/api/admin/events/:featureName' \
+-H 'Accept: application/json' \
+-H 'Authorization: '
+```
+
+Read our documentation on [Event logs](/reference/event-log) and [APIs](/reference/api/unleash/events) to learn more.
+
+
+## Change Management & Feature Flag Approvals in React
+
+Unleash makes it easy to toggle a feature. But the more users you have, the more risk with unexpected changes occurring. That’s why we implemented an approval workflow within Unleash Enterprise for making a change to a feature flag. This functions similar to GitHub's pull requests, and models a Git review workflow. You could have one or more approvals required to reduce risk of someone changing something they shouldn’t. It helps development teams to have access only to what they _need_. For example, you can use Unleash to track changes to your React feature flag’s configuration.
+
+In Unleash Enterprise, we have a change request feature in your project settings to manage your feature flag approvals. When someone working in a project needs to update the status of a flag or strategy, they can follow our approval workflow to ensure that one or more team members review the change request.
+
+
+![Project settings](/img/react-ex-project-settings.png)
+
+
+We have several statuses that indicate the stages a feature flag could be in as it progresses through the workflow. This facilitates collaboration on teams while reducing risk in environments like production. For larger scale change management, you can ensure the correct updates are made while having full visibility into who made the request for change and when.
+
+
+![Change requests](/img/react-ex-change-requests.png)
+
+
+Learn more about [how to configure your change requests](/reference/change-requests) and our [project collaboration mode](/reference/project-collaboration-mode).
+
+
+## Flag Automation & Workflow Integration for React Apps
+
+An advanced use case for leveraging feature flags at scale is flag automation in your development workflow. Many organizations use tools like Jira for managing projects and tracking releases across teams. [Our Jira integration](/reference/integrations/jira-server-plugin-installation) helps to manage feature flag lifecycles associated with your projects.
+
+It’s common for teams to have a development phase, QA/testing and then a release to production. Let’s say the changes we’ve made in our React tutorial project need to go through a typical development workflow. The [Unleash Jira plugin](https://marketplace.atlassian.com/apps/1227377/unleash-for-jira?tab=overview&hosting=datacenter) can connect to your Jira server or cloud to create feature flags automatically during the project creation phase. As your code progresses through development and Jira tickets are updated, the relevant flag can turn on in a development environment. The next stage could be canary deployments for testing code quality in subsequent environments to certain groups, like a QA team or beta users. The flag could be automatically turned on in QA and rollout and/or rollout to target audiences in production.
+
+Here’s how this can be done via our API:
+
+
+
+1. Enable a flag.
+
+```
+curl -L -X POST '/api/admin/projects/:projectId/features/:featureName/environments/:environment/on' \
+-H 'Accept: application/json' \
+-H 'Authorization: '
+```
+
+Review our [API docs on flag enablement](/reference/api/unleash/toggle-feature-environment-on).
+
+2. Update a flag.
+
+```
+curl -L -X PUT '/api/admin/projects/:projectId/features/:featureName' \
+-H 'Content-Type: application/json' \
+-H 'Accept: application/json' \
+-H 'Authorization: ' \
+--data-raw '{
+ "description": "Controls disabling of the comments section in case of an incident",
+ "type": "kill-switch",
+ "stale": true,
+ "archived": true,
+ "impressionData": false
+}'
+```
+
+And the required body field object would look like this:
+
+```js
+{
+ "description": "Controls disabling of the comments section in case of an incident",
+ "type": "kill-switch",
+ "stale": true,
+ "archived": true,
+ "impressionData": false
+}
+```
+Review our [API docs on updating feature flags](/reference/api/unleash/update-feature).
+
+3. Archive a flag.
+
+```
+curl -L -X DELETE '/api/admin/projects/:projectId/features/:featureName' \
+-H 'Authorization: '
+```
+
+Review [API docs on archiving flags](/reference/api/unleash/archive-feature).
+
+
+## Common Usage Examples of React Feature Flags
+
+To take full advantage of our React SDK, we’ve compiled a list of the most common functions to call in a React app.
+
+| Function | Description | Parameters | Output |
+| -------- | ----------- | ---------- | ------ |
+| [`useFlag`](#useflag-example)| determines whether or not the flag is enabled | feature flag name (string) | true, false (boolean) |
+| [`useVariant`](#usevariant-example)| returns the flag variant that the user falls into| feature flag name (string)| flag and flag variant data (object)
+| [`useUnleashClient`](#useunleashclient-example)| listens to client events and performs actions against them | none | |
+| [`useUnleashContext`](#useunleashcontext-example)| retrieves information related to current flag request for you to update | none | |
+| [`useFlags`](#useflags-example)| retrieves a list of all flags within your project| none | an array of each flag object data |
+| [`useFlagsStatus`](#useflagsstatus-example) | retrieves status information of al flags within your project; tells you whether they have been successfully fetched and whether there were any errors | none | an object of flag data |
+
+### `useFlag` example
+
+```js
+const newFeature = useFlag(“newFeatureFlag”);
+
+// output
+true
+```
+
+### `useVariant` example
+
+```js
+const variant = useVariant(“newFeatureFlag”);
+
+// output
+{ enabled: true, feature_enabled: true, name: “newVariant” }
+```
+
+### `useUnleashClient` example
+
+```js
+const unleashClient = useUnleashClient();
+
+unleashClient.start();
+
+unleashClient.on(“ready”, () => {
+const impressionEnabled = unleashClient.isEnabled(“newFeatureFlag”);
+ // do something here
+});
+
+unleashClient.on(“impression”, (events) => {
+ console.log(events);
+ // do something with impression data here
+});
+```
+
+### `useUnleashContext` example
+
+```js
+ const updateContext = useUnleashContext();
+
+ useEffect(() => {
+ // context is updated with userId
+ updateContext({ userId });
+ }, [userId]);
+```
+
+Read more on [Unleash Context](/reference/unleash-context) and the fields you can request and update.
+
+### `useFlags` example
+
+```js
+const allFlags = useFlags();
+
+// output
+[
+ {
+ name: 'string',
+ enabled: true,
+ variant: {
+ name: 'string',
+ enabled: false,
+ },
+ impressionData: false,
+ },
+ {
+ name: 'string',
+ enabled: true,
+ variant: {
+ name: 'string',
+ enabled: false,
+ },
+ impressionData: false,
+ },
+];
+```
+
+### `useFlagsStatus` example
+
+```js
+const flagsStatus = useFlagsStatus();
+
+// output
+{ flagsReady: true, flagsError: null }
+```
+
+
+## Additional Examples
+
+Many additional use cases exist for React, including [deferring client start](/reference/sdks/react#deferring-client-start), [usage with class components](/reference/sdks/react#usage-with-class-components), [React Native](/reference/sdks/react#react-native), and more, all of which can be found in our [React SDK documentation](/reference/sdks/react).
diff --git a/website/docs/feature-flag-tutorials/react/implementing-feature-flags.md b/website/docs/feature-flag-tutorials/react/implementing-feature-flags.md
index 637f0b689f8c..35f5e1a90723 100644
--- a/website/docs/feature-flag-tutorials/react/implementing-feature-flags.md
+++ b/website/docs/feature-flag-tutorials/react/implementing-feature-flags.md
@@ -49,13 +49,13 @@ a. Limit PII (personally identifiable information) leakage from the end-user dev
b. Avoid leakage of configuration information from the central feature flag control service to end-user devices.
-Solving both means you need to avoid evaluating feature flags on the user's machine due to security risks like exposing API keys and flag data. Instead, send application context (e.g. username, location, etc) to your feature flag evaluation service to evaluate the results. These results (and only these results) should be stored in the client-side application memory. By keeping the evaluated results for a specific context in memory, you avoid network roundtrips every time your application needs to check the status of a feature flag. This method prevents unauthorized access and data breaches by [keeping configurations and PII secure](https://docs.getunleash.io/topics/feature-flags/never-expose-pii).
+Solving both means you need to avoid evaluating feature flags on the user's machine due to security risks like exposing API keys and flag data. Instead, send application context (e.g. username, location, etc) to your feature flag evaluation service to evaluate the results. These results (and only these results) should be stored in the client-side application memory. By keeping the evaluated results for a specific context in memory, you avoid network roundtrips every time your application needs to check the status of a feature flag. This method prevents unauthorized access and data breaches by [keeping configurations and PII secure](/topics/feature-flags/never-expose-pii).
![Keep configurations and PII secure image](/img/react-tutorial-pii-diagram.png)
Unleash, the open-source feature flag system used in this tutorial, evaluates feature flags in this way, so by following the rest of these steps, you will be protecting your user’s data and your company’s reputation.
-For a complete list of architectural guidelines, see our [best practices for building and scaling feature flag systems](https://docs.getunleash.io/topics/feature-flags/feature-flag-best-practices).
+For a complete list of architectural guidelines, see our [best practices for building and scaling feature flag systems](/topics/feature-flags/feature-flag-best-practices).
## 2. Install a local feature flag provider
@@ -201,7 +201,7 @@ Next, replace the `` string in the config object with the API token
This configuration object is used to populate the `FlagProvider` component that comes from Unleash and wraps around the application, using the credentials to target the specific feature flag you created for the project.
-You can check our documentation on [API tokens and client keys](https://docs.getunleash.io/reference/api-tokens-and-client-keys) for more specifics and see additional use-cases in our [Client-Side SDK with React](https://docs.getunleash.io/reference/sdks/react) documentation.
+You can check our documentation on [API tokens and client keys](/reference/api-tokens-and-client-keys) for more specifics and see additional use-cases in our [Client-Side SDK with React](/reference/sdks/react) documentation.
## 5. Use the feature flag to rollout a notifications badge
diff --git a/website/docs/reference/api-tokens-and-client-keys.mdx b/website/docs/reference/api-tokens-and-client-keys.mdx
index 2d86b46f5c2a..0c793ca0e11e 100644
--- a/website/docs/reference/api-tokens-and-client-keys.mdx
+++ b/website/docs/reference/api-tokens-and-client-keys.mdx
@@ -6,7 +6,7 @@ For Unleash to be of any use, it requires at least a server and a [consuming cli
This document details the three kinds of tokens and keys that you will need to fully connect any Unleash system:
-- [Admin tokens](#admin-tokens) for updating resources in Unleash
+- [Admin tokens](#admin-tokens) for updating resources in Unleash (**Deprecated**) We recommend you use [Personal Access Tokens](#personal-access-tokens) instead.
- [Client tokens](#client-tokens) for connecting server-side client SDKs and the Unleash proxy to the Unleash server
- [Proxy client keys](#proxy-client-keys) for connecting proxy client SDKs to the Unleash proxy.
@@ -20,7 +20,7 @@ This section describes what API tokens are. For information on how to create the
Use API tokens to connect to the Unleash server API. API tokens come in four distinct types:
-- [Admin tokens](#admin-tokens)
+- [Admin tokens](#admin-tokens) (**Deprecated**)
- [Personal access tokens](#personal-access-tokens)
- [Client tokens](#client-tokens)
- [Front-end tokens](#front-end-tokens)
@@ -62,13 +62,16 @@ Use admin tokens to:
- Automate Unleash behavior such as creating feature toggles, projects, etc.
- Write custom Unleash UIs to replace the default Unleash admin UI.
-
Do **not** use admin tokens for:
- [Client SDKs](../reference/sdks/index.md): You will _not_ be able to read toggle data from multiple environments. Use [client tokens](#client-tokens) instead.
Support for scoped admin tokens with more fine-grained permissions is currently in the planning stage.
+**Deprecation Notice**
+We do not recommend using admin tokens anymore, they are not connected to any user, and as such is a lot harder to track.
+* For OSS and Pro users, we recommend using [Personal Access Tokens](#personal-access-tokens) instead.
+* Enterprise users have the option to use [Service accounts](./service-accounts).
diff --git a/website/package.json b/website/package.json
index 60c21a39c3b4..7dcd27a69c37 100644
--- a/website/package.json
+++ b/website/package.json
@@ -75,7 +75,7 @@
"@tsconfig/docusaurus": "2.0.2",
"babel-loader": "9.1.3",
"enhanced-resolve": "5.15.0",
- "react-router": "6.21.1",
+ "react-router": "6.21.2",
"replace-in-file": "7.1.0",
"typescript": "4.8.4"
}
diff --git a/website/sidebars.js b/website/sidebars.js
index 8f142d43ac53..47a6c7b394f8 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -9,8 +9,8 @@
Create as many sidebars as you want.
*/
- // TODO: Add warning to legacy API docs - but generated items
- // TODO: Continue to clean URLs & redirects - but wait for SEO results first
+// TODO: Add warning to legacy API docs - but generated items
+// TODO: Continue to clean URLs & redirects - but wait for SEO results first
module.exports = {
@@ -92,7 +92,17 @@ module.exports = {
slug: 'feature-flag-tutorials',
},
items: [
- 'feature-flag-tutorials/react/implementing-feature-flags',
+ {
+ type: 'category',
+ label: 'How to Implement Feature Feature Flags in React',
+ link: {
+ type: 'doc',
+ id: 'feature-flag-tutorials/react/implementing-feature-flags',
+ },
+ items: [
+ 'feature-flag-tutorials/react/examples',
+ ],
+ },
'feature-flag-tutorials/flutter/a-b-testing',
'feature-flag-tutorials/nextjs/implementing-feature-flags',
],
diff --git a/website/static/img/react-ex-add-constraint-btn.png b/website/static/img/react-ex-add-constraint-btn.png
new file mode 100644
index 000000000000..9d24910df883
Binary files /dev/null and b/website/static/img/react-ex-add-constraint-btn.png differ
diff --git a/website/static/img/react-ex-add-variant.png b/website/static/img/react-ex-add-variant.png
new file mode 100644
index 000000000000..5597766a69e0
Binary files /dev/null and b/website/static/img/react-ex-add-variant.png differ
diff --git a/website/static/img/react-ex-change-requests.png b/website/static/img/react-ex-change-requests.png
new file mode 100644
index 000000000000..a0b61d61933b
Binary files /dev/null and b/website/static/img/react-ex-change-requests.png differ
diff --git a/website/static/img/react-ex-compare-icons.png b/website/static/img/react-ex-compare-icons.png
new file mode 100644
index 000000000000..695342b4775a
Binary files /dev/null and b/website/static/img/react-ex-compare-icons.png differ
diff --git a/website/static/img/react-ex-constraint-form.png b/website/static/img/react-ex-constraint-form.png
new file mode 100644
index 000000000000..e2f07848f81a
Binary files /dev/null and b/website/static/img/react-ex-constraint-form.png differ
diff --git a/website/static/img/react-ex-edit-settings.png b/website/static/img/react-ex-edit-settings.png
new file mode 100644
index 000000000000..ada19e56b86a
Binary files /dev/null and b/website/static/img/react-ex-edit-settings.png differ
diff --git a/website/static/img/react-ex-enable-impression-data.png b/website/static/img/react-ex-enable-impression-data.png
new file mode 100644
index 000000000000..3bc48072ccd9
Binary files /dev/null and b/website/static/img/react-ex-enable-impression-data.png differ
diff --git a/website/static/img/react-ex-event-log.png b/website/static/img/react-ex-event-log.png
new file mode 100644
index 000000000000..f3cbcefe8e3b
Binary files /dev/null and b/website/static/img/react-ex-event-log.png differ
diff --git a/website/static/img/react-ex-grad-rollout-edit.png b/website/static/img/react-ex-grad-rollout-edit.png
new file mode 100644
index 000000000000..1a2b2c213098
Binary files /dev/null and b/website/static/img/react-ex-grad-rollout-edit.png differ
diff --git a/website/static/img/react-ex-grad-rollout-form.png b/website/static/img/react-ex-grad-rollout-form.png
new file mode 100644
index 000000000000..8479ca5add2d
Binary files /dev/null and b/website/static/img/react-ex-grad-rollout-form.png differ
diff --git a/website/static/img/react-ex-metrics.png b/website/static/img/react-ex-metrics.png
new file mode 100644
index 000000000000..d4490bf1a809
Binary files /dev/null and b/website/static/img/react-ex-metrics.png differ
diff --git a/website/static/img/react-ex-project-settings.png b/website/static/img/react-ex-project-settings.png
new file mode 100644
index 000000000000..4163c1af0762
Binary files /dev/null and b/website/static/img/react-ex-project-settings.png differ
diff --git a/website/static/img/react-ex-rollout-with-constraint.png b/website/static/img/react-ex-rollout-with-constraint.png
new file mode 100644
index 000000000000..f1cacb3bbaa3
Binary files /dev/null and b/website/static/img/react-ex-rollout-with-constraint.png differ
diff --git a/website/static/img/react-ex-variant-added.png b/website/static/img/react-ex-variant-added.png
new file mode 100644
index 000000000000..287c10f38ae1
Binary files /dev/null and b/website/static/img/react-ex-variant-added.png differ
diff --git a/website/static/img/react-ex-variant-form.png b/website/static/img/react-ex-variant-form.png
new file mode 100644
index 000000000000..297241fc6191
Binary files /dev/null and b/website/static/img/react-ex-variant-form.png differ
diff --git a/website/vercel.json b/website/vercel.json
index 7c9c4baf1ae5..56a9eb736432 100644
--- a/website/vercel.json
+++ b/website/vercel.json
@@ -1,164 +1,779 @@
{
"cleanUrls": true,
"rewrites": [
- { "source": "/helm-charts/index.yaml", "destination": "https://raw.githubusercontent.com/Unleash/helm-charts/gh-pages/index.yaml" },
- { "source": "/helm-charts(/?)", "destination": "https://raw.githubusercontent.com/Unleash/helm-charts/gh-pages/README.md" },
- { "source": "/helm-charts(/?):path(.*)", "destination": "https://gh.getunleash.io/helm-charts/:path" }
+ {
+ "source": "/helm-charts/index.yaml",
+ "destination": "https://raw.githubusercontent.com/Unleash/helm-charts/gh-pages/index.yaml"
+ },
+ {
+ "source": "/helm-charts(/?)",
+ "destination": "https://raw.githubusercontent.com/Unleash/helm-charts/gh-pages/README.md"
+ },
+ {
+ "source": "/helm-charts(/?):path(.*)",
+ "destination": "https://gh.getunleash.io/helm-charts/:path"
+ }
],
"redirects": [
- { "source": "/unleash-client-python(/?)/:path", "destination": "https://gh.getunleash.io/unleash-client-python/:path", "permanent": true },
- { "source": "/user_guide/api-token", "destination": "/how-to/how-to-create-api-tokens", "permanent": true },
- { "source": "/deploy/user_guide/api-token", "destination": "/how-to/how-to-create-api-tokens", "permanent": true },
- { "source": "/advanced/audit_log", "destination": "/reference/event-log", "permanent": true },
- { "source": "/api/open_api", "destination": "/reference/api/unleash", "permanent": true },
- { "source": "/advanced/api_access", "destination": "/how-to/how-to-use-the-admin-api", "permanent": true },
- { "source": "/advanced/archived_toggles", "destination": "/reference/archived-toggles", "permanent": true },
- { "source": "/advanced/custom-activation-strategy", "destination": "/reference/custom-activation-strategies", "permanent": true },
- { "source": "/advanced/custom_activation_strategy", "destination": "/reference/custom-activation-strategies", "permanent": true },
- { "source": "/advanced/feature_toggle_types", "destination": "/reference/feature-toggle-types", "permanent": true },
- { "source": "/toggle_variants", "destination": "/reference/feature-toggle-variants", "permanent": true },
- { "source": "/advanced/feature_toggle_variants", "destination": "/reference/feature-toggle-variants", "permanent": true },
- { "source": "/advanced/toggle_variants", "destination": "/reference/feature-toggle-variants", "permanent": true },
- { "source": "/advanced/impression-data", "destination": "/reference/impression-data", "permanent": true },
- { "source": "/advanced/impression_data", "destination": "/reference/impression-data", "permanent": true },
- { "source": "/advanced/stickiness", "destination": "/reference/stickiness", "permanent": true },
- { "source": "/advanced/sso-google", "destination": "/how-to/how-to-add-sso-google", "permanent": true },
- { "source": "/advanced/sso-open-id-connect", "destination": "/how-to/how-to-add-sso-open-id-connect", "permanent": true },
- { "source": "/advanced/sso-saml-keycloak", "destination": "/how-to/how-to-add-sso-saml-keycloak", "permanent": true },
- { "source": "/advanced/sso-saml", "destination": "/how-to/how-to-add-sso-saml", "permanent": true },
- { "source": "/advanced/strategy_constraints", "destination": "/reference/strategy-constraints", "permanent": true },
- { "source": "/advanced/tags", "destination": "/reference/tags", "permanent": true },
- { "source": "/advanced/enterprise-authentication", "destination": "/reference/sso", "permanent": true },
- { "source": "/addons", "destination": "/reference/integrations", "permanent": true },
- { "source": "/reference/addons", "destination": "/reference/integrations", "permanent": true },
- { "source": "/addons/datadog", "destination": "/reference/integrations/datadog", "permanent": true },
- { "source": "/reference/addons/datadog", "destination": "/reference/integrations/datadog", "permanent": true },
- { "source": "/addons/slack", "destination": "/reference/integrations/slack", "permanent": true },
- { "source": "/reference/addons/slack", "destination": "/reference/integrations/slack", "permanent": true },
- { "source": "/addons/slack-app", "destination": "/reference/integrations/slack-app", "permanent": true },
- { "source": "/reference/addons/slack-app", "destination": "/reference/integrations/slack-app", "permanent": true },
- { "source": "/addons/teams", "destination": "/reference/integrations/teams", "permanent": true },
- { "source": "/reference/addons/teams", "destination": "/reference/integrations/teams", "permanent": true },
- { "source": "/addons/webhook", "destination": "/reference/integrations/webhook", "permanent": true },
- { "source": "/reference/addons/webhook", "destination": "/reference/integrations/webhook", "permanent": true },
- { "source": "/guides/feature_updates_to_slack", "destination": "/how-to/how-to-send-feature-updates-to-slack-deprecated", "permanent": true },
- { "source": "/integrations/integrations", "destination": "/reference/integrations", "permanent": true },
- { "source": "/integrations", "destination": "/reference/integrations", "permanent": true },
- { "source": "/integrations/jira_server_plugin_installation", "destination": "/reference/integrations/jira-server-plugin-installation", "permanent": true },
- { "source": "/integrations/jira_server_plugin_usage", "destination": "/reference/integrations/jira-server-plugin-usage", "permanent": true },
- { "source": "/sdks", "destination": "/reference/sdks", "permanent": true },
- { "source": "/user_guide/client-sdk", "destination": "/reference/sdks", "permanent": true },
- { "source": "/client-sdk", "destination": "/reference/sdks", "permanent": true },
- { "source": "/user_guide/connect_sdk", "destination": "/reference/sdks", "permanent": true },
- { "source": "/sdks/community", "destination": "/reference/sdks", "permanent": true },
- { "source": "/sdks/go_sdk", "destination": "/reference/sdks/go", "permanent": true },
- { "source": "/sdks/java_sdk", "destination": "/reference/sdks/java", "permanent": true },
- { "source": "/sdks/node_sdk", "destination": "/reference/sdks/node", "permanent": true },
- { "source": "/sdks/php_sdk", "destination": "/reference/sdks/php", "permanent": true },
- { "source": "/sdks/python_sdk", "destination": "/reference/sdks/python", "permanent": true },
- { "source": "/sdks/dot_net_sdk", "destination": "/reference/sdks/dotnet", "permanent": true },
- { "source": "/sdks/ruby_sdk", "destination": "/reference/sdks/ruby", "permanent": true },
- { "source": "/sdks/android_proxy_sdk", "destination": "/reference/sdks/android-proxy", "permanent": true },
- { "source": "/sdks/proxy-ios", "destination": "/reference/sdks/ios-proxy", "permanent": true },
- { "source": "/sdks/proxy-javascript", "destination": "/reference/sdks/javascript-browser", "permanent": true },
- { "source": "/sdks/javascript-browser", "destination": "/reference/sdks/javascript-browser", "permanent": true },
- { "source": "/sdks/proxy-react", "destination": "/reference/sdks/react", "permanent": true },
- { "source": "/sdks/react", "destination": "/reference/sdks/react", "permanent": true },
- { "source": "/sdks/proxy-vue", "destination": "/reference/sdks/vue", "permanent": true },
- { "source": "/sdks/proxy-svelte", "destination": "/reference/sdks/svelte", "permanent": true },
- { "source": "/user_guide/native_apps", "destination": "/reference/unleash-proxy", "permanent": true },
- { "source": "/user_guide/proxy-api", "destination": "/reference/unleash-proxy", "permanent": true },
- { "source": "/sdks/unleash-proxy", "destination": "/reference/unleash-proxy", "permanent": true },
- { "source": "/user_guide/create_feature_toggle", "destination": "/how-to/how-to-create-feature-toggles", "permanent": true },
- { "source": "/user_guide/control_rollout", "destination": "/reference/activation-strategies", "permanent": true },
- { "source": "/user_guide/activation_strategy", "destination": "/reference/activation-strategies", "permanent": true },
- { "source": "/user_guide/environments", "destination": "/reference/environments", "permanent": true },
- { "source": "/user_guide/projects", "destination": "/reference/projects", "permanent": true },
- { "source": "/user_guide/rbac", "destination": "/reference/rbac", "permanent": true },
- { "source": "/advanced/groups", "destination": "/reference/rbac", "permanent": true },
- { "source": "/user_guide/technical_debt", "destination": "/reference/technical-debt", "permanent": true },
- { "source": "/user_guide/unleash_context", "destination": "/reference/unleash-context", "permanent": true },
- { "source": "/user_guide/user-management", "destination": "/how-to/how-to-add-users-to-unleash", "permanent": true },
- { "source": "/user_guide/v4-whats-new", "destination": "/reference/whats-new-v4", "permanent": true },
- { "source": "/user_guide/important-concepts", "destination": "/reference", "permanent": true },
- { "source": "/tutorials/important-concepts", "destination": "/reference", "permanent": true },
- { "source": "/reference/concepts/", "destination": "/reference", "permanent": true },
- { "source": "/user_guide/quickstart", "destination": "/quickstart", "permanent": true },
- { "source": "/docs/getting_started", "destination": "/quickstart", "permanent": true },
- { "source": "/tutorials/quickstart", "destination": "/quickstart", "permanent": true },
- { "source": "/tutorials/getting-started", "destination": "/quickstart", "permanent": true },
- { "source": "/api/basic-auth", "destination": "/reference/api/legacy/unleash/basic-auth", "permanent": true },
- { "source": "/api", "destination": "/reference/api/legacy/unleash", "permanent": true },
- { "source": "/api/admin/addons", "destination": "/reference/api/legacy/unleash/admin/addons", "permanent": true },
- { "source": "/api/admin/context", "destination": "/reference/api/legacy/unleash/admin/context", "permanent": true },
- { "source": "/api/admin/events", "destination": "/reference/api/legacy/unleash/admin/events", "permanent": true },
- { "source": "/api/admin/feature-toggles-v2", "destination": "/reference/api/legacy/unleash/admin/features-v2", "permanent": true },
- { "source": "/api/admin/feature-types", "destination": "/reference/api/legacy/unleash/admin/feature-types", "permanent": true },
- { "source": "/api/admin/features", "destination": "/reference/api/legacy/unleash/admin/features", "permanent": true },
- { "source": "/api/admin/features-archive", "destination": "/reference/api/legacy/unleash/admin/archive", "permanent": true },
- { "source": "/api/admin/metrics", "destination": "/reference/api/legacy/unleash/admin/metrics", "permanent": true },
- { "source": "/api/admin/projects", "destination": "/reference/api/legacy/unleash/admin/projects", "permanent": true },
- { "source": "/api/admin/segments", "destination": "/reference/api/legacy/unleash/admin/segments", "permanent": true },
- { "source": "/api/admin/state", "destination": "/reference/api/legacy/unleash/admin/state", "permanent": true },
- { "source": "/api/admin/strategies", "destination": "/reference/api/legacy/unleash/admin/strategies", "permanent": true },
- { "source": "/api/admin/tags", "destination": "/reference/api/legacy/unleash/admin/tags", "permanent": true },
- { "source": "/api/admin/user-admin", "destination": "/reference/api/legacy/unleash/admin/user-admin", "permanent": true },
- { "source": "/api/client/features", "destination": "/reference/api/legacy/unleash/client/features", "permanent": true },
- { "source": "/api/client/metrics", "destination": "/reference/api/legacy/unleash/client/metrics", "permanent": true },
- { "source": "/api/client/register", "destination": "/reference/api/legacy/unleash/client/register", "permanent": true },
- { "source": "/api/internal/internal", "destination": "/reference/api/legacy/unleash/internal/prometheus", "permanent": true },
- { "source": "/api/internal/health", "destination": "/reference/api/legacy/unleash/internal/health", "permanent": true },
- { "source": "/help", "destination": "/", "permanent": true },
- { "source": "/topics/feature-flags/tutorials", "destination": "/feature-flag-tutorials", "permanent": true },
- { "source": "/tutorials", "destination": "/feature-flag-tutorials", "permanent": true },
- { "source": "/topics/feature-flags/tutorials/react/implementing-feature-flags", "destination": "/feature-flag-tutorials/react", "permanent": true },
- { "source": "/topics/feature-flags/tutorials/flutter/a-b-testing", "destination": "/feature-flag-tutorials/flutter/a-b-testing", "permanent": true },
- { "source": "/topics/feature-flags/tutorials/nextjs/implementing-feature-flags", "destination": "/feature-flag-tutorials/nextjs/implementing-feature-flags", "permanent": true },
- { "source": "/tutorials/academy", "destination": "/unleash-academy/introduction", "permanent": true },
- { "source": "/unleash-academy", "destination": "/unleash-academy/introduction", "permanent": true },
- { "source": "/tutorials/academy-foundational", "destination": "/unleash-academy/foundational", "permanent": true },
- { "source": "/tutorials/academy-advanced-for-devs", "destination": "/unleash-academy/advanced-for-devs", "permanent": true },
- { "source": "/tutorials/academy-managing-unleash-for-devops", "destination": "/unleash-academy/managing-unleash-for-devops", "permanent": true },
- { "source": "/developer-guide", "destination": "/contributing", "permanent": true },
- { "source": "/tutorials/unleash-overview", "destination": "/understanding-unleash/unleash-overview", "permanent": true },
- { "source": "/user_guide/unleash_overview", "destination": "/understanding-unleash/unleash-overview", "permanent": true },
- { "source": "/tutorials/managing-constraints", "destination": "/understanding-unleash/managing-constraints", "permanent": true },
- { "source": "/topics/managing-constraints", "destination": "/understanding-unleash/managing-constraints", "permanent": true },
- { "source": "/tutorials/the-anatomy-of-unleash", "destination": "/understanding-unleash/the-anatomy-of-unleash", "permanent": true },
- { "source": "/topics/the-anatomy-of-unleash", "destination": "/understanding-unleash/the-anatomy-of-unleash", "permanent": true },
- { "source": "/tutorials/proxy-hosting", "destination": "/understanding-unleash/proxy-hosting", "permanent": true },
- { "source": "/topics/proxy-hosting", "destination": "/understanding-unleash/proxy-hosting", "permanent": true },
- { "source": "/tutorials/data-collection", "destination": "/understanding-unleash/data-collection", "permanent": true },
- { "source": "/topics/data-collection", "destination": "/understanding-unleash/data-collection", "permanent": true },
- { "source": "/how-to/how-to-troubleshoot-flag-exposure", "destination": "/using-unleash/troubleshooting/flag-exposure", "permanent": true },
- { "source": "/how-to/how-to-troubleshoot-flag-not-returned", "destination": "/using-unleash/troubleshooting/flag-not-returned", "permanent": true },
- { "source": "/how-to/how-to-troubleshoot-cors", "destination": "/using-unleash/troubleshooting/cors", "permanent": true },
- { "source": "/how-to/how-to-troubleshoot-feature-not-available", "destination": "/using-unleash/troubleshooting/feature-not-available", "permanent": true },
- { "source": "/reference/deploy", "destination": "/using-unleash/deploy", "permanent": true },
- { "source": "/deploy", "destination": "/using-unleash/deploy", "permanent": true },
- { "source": "/reference/deploy/getting-started", "destination": "/using-unleash/deploy/getting-started", "permanent": true },
- { "source": "/deploy/getting_started", "destination": "/using-unleash/deploy/getting-started", "permanent": true },
- { "source": "/reference/deploy/configuring-unleash", "destination": "/using-unleash/deploy/configuring-unleash", "permanent": true },
- { "source": "/deploy/configuring_unleash", "destination": "/using-unleash/deploy/configuring-unleash", "permanent": true },
- { "source": "/reference/deploy/configuring-unleash-v3", "destination": "/using-unleash/deploy/configuring-unleash-v3", "permanent": true },
- { "source": "/deploy/configuring_unleash_v3", "destination": "/using-unleash/deploy/configuring-unleash-v3", "permanent": true },
- { "source": "/reference/deploy/database-setup", "destination": "/using-unleash/deploy/database-setup", "permanent": true },
- { "source": "/deploy/database-setup", "destination": "/using-unleash/deploy/database-setup", "permanent": true },
- { "source": "/reference/deploy/database-backup", "destination": "/using-unleash/deploy/database-backup", "permanent": true },
- { "source": "/deploy/database-backup", "destination": "/using-unleash/deploy/database-backup", "permanent": true },
- { "source": "/reference/deploy/email-service", "destination": "/using-unleash/deploy/email-service", "permanent": true },
- { "source": "/deploy/email", "destination": "/using-unleash/deploy/email-service", "permanent": true },
- { "source": "/reference/deploy/google-auth-hook", "destination": "/using-unleash/deploy/google-auth-hook", "permanent": true },
- { "source": "/deploy/google_auth", "destination": "/using-unleash/deploy/google-auth-hook", "permanent": true },
- { "source": "/deploy/migration_guide", "destination": "/using-unleash/deploy/upgrading-unleash", "permanent": true },
- { "source": "/reference/deploy/migration-guide", "destination": "/using-unleash/deploy/upgrading-unleash", "permanent": true },
- { "source": "/reference/deploy/securing-unleash", "destination": "/using-unleash/deploy/securing-unleash", "permanent": true },
- { "source": "/deploy/securing_unleash", "destination": "/using-unleash/deploy/securing-unleash", "permanent": true },
- { "source": "/reference/deploy/import-export", "destination": "/how-to/how-to-import-export", "permanent": true },
- { "source": "/deploy/import_export", "destination": "/how-to/how-to-import-export", "permanent": true },
- { "source": "/reference/deploy/environment-import-export", "destination": "/how-to/how-to-environment-import-export", "permanent": true },
- { "source": "/deploy/environment-import-export", "destination": "/how-to/how-to-environment-import-export", "permanent": true }
+ {
+ "source": "/unleash-client-python(/?)/:path",
+ "destination": "https://gh.getunleash.io/unleash-client-python/:path",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/api-token",
+ "destination": "/how-to/how-to-create-api-tokens",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/user_guide/api-token",
+ "destination": "/how-to/how-to-create-api-tokens",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/audit_log",
+ "destination": "/reference/event-log",
+ "permanent": true
+ },
+ {
+ "source": "/api/open_api",
+ "destination": "/reference/api/unleash",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/api_access",
+ "destination": "/how-to/how-to-use-the-admin-api",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/archived_toggles",
+ "destination": "/reference/archived-toggles",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/custom-activation-strategy",
+ "destination": "/reference/custom-activation-strategies",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/custom_activation_strategy",
+ "destination": "/reference/custom-activation-strategies",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/feature_toggle_types",
+ "destination": "/reference/feature-toggle-types",
+ "permanent": true
+ },
+ {
+ "source": "/toggle_variants",
+ "destination": "/reference/feature-toggle-variants",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/feature_toggle_variants",
+ "destination": "/reference/feature-toggle-variants",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/toggle_variants",
+ "destination": "/reference/feature-toggle-variants",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/impression-data",
+ "destination": "/reference/impression-data",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/impression_data",
+ "destination": "/reference/impression-data",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/stickiness",
+ "destination": "/reference/stickiness",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/sso-google",
+ "destination": "/how-to/how-to-add-sso-google",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/sso-open-id-connect",
+ "destination": "/how-to/how-to-add-sso-open-id-connect",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/sso-saml-keycloak",
+ "destination": "/how-to/how-to-add-sso-saml-keycloak",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/sso-saml",
+ "destination": "/how-to/how-to-add-sso-saml",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/strategy_constraints",
+ "destination": "/reference/strategy-constraints",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/tags",
+ "destination": "/reference/tags",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/enterprise-authentication",
+ "destination": "/reference/sso",
+ "permanent": true
+ },
+ {
+ "source": "/addons",
+ "destination": "/reference/integrations",
+ "permanent": true
+ },
+ {
+ "source": "/reference/addons",
+ "destination": "/reference/integrations",
+ "permanent": true
+ },
+ {
+ "source": "/addons/datadog",
+ "destination": "/reference/integrations/datadog",
+ "permanent": true
+ },
+ {
+ "source": "/reference/addons/datadog",
+ "destination": "/reference/integrations/datadog",
+ "permanent": true
+ },
+ {
+ "source": "/addons/slack",
+ "destination": "/reference/integrations/slack",
+ "permanent": true
+ },
+ {
+ "source": "/reference/addons/slack",
+ "destination": "/reference/integrations/slack",
+ "permanent": true
+ },
+ {
+ "source": "/addons/slack-app",
+ "destination": "/reference/integrations/slack-app",
+ "permanent": true
+ },
+ {
+ "source": "/reference/addons/slack-app",
+ "destination": "/reference/integrations/slack-app",
+ "permanent": true
+ },
+ {
+ "source": "/addons/teams",
+ "destination": "/reference/integrations/teams",
+ "permanent": true
+ },
+ {
+ "source": "/reference/addons/teams",
+ "destination": "/reference/integrations/teams",
+ "permanent": true
+ },
+ {
+ "source": "/addons/webhook",
+ "destination": "/reference/integrations/webhook",
+ "permanent": true
+ },
+ {
+ "source": "/reference/addons/webhook",
+ "destination": "/reference/integrations/webhook",
+ "permanent": true
+ },
+ {
+ "source": "/guides/feature_updates_to_slack",
+ "destination": "/how-to/how-to-send-feature-updates-to-slack-deprecated",
+ "permanent": true
+ },
+ {
+ "source": "/integrations/integrations",
+ "destination": "/reference/integrations",
+ "permanent": true
+ },
+ {
+ "source": "/integrations",
+ "destination": "/reference/integrations",
+ "permanent": true
+ },
+ {
+ "source": "/integrations/jira_server_plugin_installation",
+ "destination": "/reference/integrations/jira-server-plugin-installation",
+ "permanent": true
+ },
+ {
+ "source": "/integrations/jira_server_plugin_usage",
+ "destination": "/reference/integrations/jira-server-plugin-usage",
+ "permanent": true
+ },
+ {
+ "source": "/sdks",
+ "destination": "/reference/sdks",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/client-sdk",
+ "destination": "/reference/sdks",
+ "permanent": true
+ },
+ {
+ "source": "/client-sdk",
+ "destination": "/reference/sdks",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/connect_sdk",
+ "destination": "/reference/sdks",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/community",
+ "destination": "/reference/sdks",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/go_sdk",
+ "destination": "/reference/sdks/go",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/java_sdk",
+ "destination": "/reference/sdks/java",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/node_sdk",
+ "destination": "/reference/sdks/node",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/php_sdk",
+ "destination": "/reference/sdks/php",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/python_sdk",
+ "destination": "/reference/sdks/python",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/dot_net_sdk",
+ "destination": "/reference/sdks/dotnet",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/ruby_sdk",
+ "destination": "/reference/sdks/ruby",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/android_proxy_sdk",
+ "destination": "/reference/sdks/android-proxy",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/proxy-ios",
+ "destination": "/reference/sdks/ios-proxy",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/proxy-javascript",
+ "destination": "/reference/sdks/javascript-browser",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/javascript-browser",
+ "destination": "/reference/sdks/javascript-browser",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/proxy-react",
+ "destination": "/reference/sdks/react",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/react",
+ "destination": "/reference/sdks/react",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/proxy-vue",
+ "destination": "/reference/sdks/vue",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/proxy-svelte",
+ "destination": "/reference/sdks/svelte",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/native_apps",
+ "destination": "/reference/unleash-proxy",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/proxy-api",
+ "destination": "/reference/unleash-proxy",
+ "permanent": true
+ },
+ {
+ "source": "/sdks/unleash-proxy",
+ "destination": "/reference/unleash-proxy",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/create_feature_toggle",
+ "destination": "/how-to/how-to-create-feature-toggles",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/control_rollout",
+ "destination": "/reference/activation-strategies",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/activation_strategy",
+ "destination": "/reference/activation-strategies",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/environments",
+ "destination": "/reference/environments",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/projects",
+ "destination": "/reference/projects",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/rbac",
+ "destination": "/reference/rbac",
+ "permanent": true
+ },
+ {
+ "source": "/advanced/groups",
+ "destination": "/reference/rbac",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/technical_debt",
+ "destination": "/reference/technical-debt",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/unleash_context",
+ "destination": "/reference/unleash-context",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/user-management",
+ "destination": "/how-to/how-to-add-users-to-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/v4-whats-new",
+ "destination": "/reference/whats-new-v4",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/important-concepts",
+ "destination": "/reference",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/important-concepts",
+ "destination": "/reference",
+ "permanent": true
+ },
+ {
+ "source": "/reference/concepts/",
+ "destination": "/reference",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/quickstart",
+ "destination": "/quickstart",
+ "permanent": true
+ },
+ {
+ "source": "/docs/getting_started",
+ "destination": "/quickstart",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/quickstart",
+ "destination": "/quickstart",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/getting-started",
+ "destination": "/quickstart",
+ "permanent": true
+ },
+ {
+ "source": "/api/basic-auth",
+ "destination": "/reference/api/legacy/unleash/basic-auth",
+ "permanent": true
+ },
+ {
+ "source": "/api",
+ "destination": "/reference/api/legacy/unleash",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/addons",
+ "destination": "/reference/api/legacy/unleash/admin/addons",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/context",
+ "destination": "/reference/api/legacy/unleash/admin/context",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/events",
+ "destination": "/reference/api/legacy/unleash/admin/events",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/feature-toggles-v2",
+ "destination": "/reference/api/legacy/unleash/admin/features-v2",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/feature-types",
+ "destination": "/reference/api/legacy/unleash/admin/feature-types",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/features",
+ "destination": "/reference/api/legacy/unleash/admin/features",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/features-archive",
+ "destination": "/reference/api/legacy/unleash/admin/archive",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/metrics",
+ "destination": "/reference/api/legacy/unleash/admin/metrics",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/projects",
+ "destination": "/reference/api/legacy/unleash/admin/projects",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/segments",
+ "destination": "/reference/api/legacy/unleash/admin/segments",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/state",
+ "destination": "/reference/api/legacy/unleash/admin/state",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/strategies",
+ "destination": "/reference/api/legacy/unleash/admin/strategies",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/tags",
+ "destination": "/reference/api/legacy/unleash/admin/tags",
+ "permanent": true
+ },
+ {
+ "source": "/api/admin/user-admin",
+ "destination": "/reference/api/legacy/unleash/admin/user-admin",
+ "permanent": true
+ },
+ {
+ "source": "/api/client/features",
+ "destination": "/reference/api/legacy/unleash/client/features",
+ "permanent": true
+ },
+ {
+ "source": "/api/client/metrics",
+ "destination": "/reference/api/legacy/unleash/client/metrics",
+ "permanent": true
+ },
+ {
+ "source": "/api/client/register",
+ "destination": "/reference/api/legacy/unleash/client/register",
+ "permanent": true
+ },
+ {
+ "source": "/api/internal/internal",
+ "destination": "/reference/api/legacy/unleash/internal/prometheus",
+ "permanent": true
+ },
+ {
+ "source": "/api/internal/health",
+ "destination": "/reference/api/legacy/unleash/internal/health",
+ "permanent": true
+ },
+ {
+ "source": "/help",
+ "destination": "/",
+ "permanent": true
+ },
+ {
+ "source": "/topics/feature-flags/tutorials",
+ "destination": "/feature-flag-tutorials",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials",
+ "destination": "/feature-flag-tutorials",
+ "permanent": true
+ },
+ {
+ "source": "/topics/feature-flags/tutorials/react/implementing-feature-flags",
+ "destination": "/feature-flag-tutorials/react",
+ "permanent": true
+ },
+ {
+ "source": "/topics/feature-flags/tutorials/flutter/a-b-testing",
+ "destination": "/feature-flag-tutorials/flutter/a-b-testing",
+ "permanent": true
+ },
+ {
+ "source": "/topics/feature-flags/tutorials/nextjs/implementing-feature-flags",
+ "destination": "/feature-flag-tutorials/nextjs/implementing-feature-flags",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/academy",
+ "destination": "/unleash-academy/introduction",
+ "permanent": true
+ },
+ {
+ "source": "/unleash-academy",
+ "destination": "/unleash-academy/introduction",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/academy-foundational",
+ "destination": "/unleash-academy/foundational",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/academy-advanced-for-devs",
+ "destination": "/unleash-academy/advanced-for-devs",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/academy-managing-unleash-for-devops",
+ "destination": "/unleash-academy/managing-unleash-for-devops",
+ "permanent": true
+ },
+ {
+ "source": "/developer-guide",
+ "destination": "/contributing",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/unleash-overview",
+ "destination": "/understanding-unleash/unleash-overview",
+ "permanent": true
+ },
+ {
+ "source": "/user_guide/unleash_overview",
+ "destination": "/understanding-unleash/unleash-overview",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/managing-constraints",
+ "destination": "/understanding-unleash/managing-constraints",
+ "permanent": true
+ },
+ {
+ "source": "/topics/managing-constraints",
+ "destination": "/understanding-unleash/managing-constraints",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/the-anatomy-of-unleash",
+ "destination": "/understanding-unleash/the-anatomy-of-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/topics/the-anatomy-of-unleash",
+ "destination": "/understanding-unleash/the-anatomy-of-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/proxy-hosting",
+ "destination": "/understanding-unleash/proxy-hosting",
+ "permanent": true
+ },
+ {
+ "source": "/topics/proxy-hosting",
+ "destination": "/understanding-unleash/proxy-hosting",
+ "permanent": true
+ },
+ {
+ "source": "/tutorials/data-collection",
+ "destination": "/understanding-unleash/data-collection",
+ "permanent": true
+ },
+ {
+ "source": "/topics/data-collection",
+ "destination": "/understanding-unleash/data-collection",
+ "permanent": true
+ },
+ {
+ "source": "/how-to/how-to-troubleshoot-flag-exposure",
+ "destination": "/using-unleash/troubleshooting/flag-exposure",
+ "permanent": true
+ },
+ {
+ "source": "/how-to/how-to-troubleshoot-flag-not-returned",
+ "destination": "/using-unleash/troubleshooting/flag-not-returned",
+ "permanent": true
+ },
+ {
+ "source": "/how-to/how-to-troubleshoot-cors",
+ "destination": "/using-unleash/troubleshooting/cors",
+ "permanent": true
+ },
+ {
+ "source": "/how-to/how-to-troubleshoot-feature-not-available",
+ "destination": "/using-unleash/troubleshooting/feature-not-available",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy",
+ "destination": "/using-unleash/deploy",
+ "permanent": true
+ },
+ {
+ "source": "/deploy",
+ "destination": "/using-unleash/deploy",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/getting-started",
+ "destination": "/using-unleash/deploy/getting-started",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/getting_started",
+ "destination": "/using-unleash/deploy/getting-started",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/configuring-unleash",
+ "destination": "/using-unleash/deploy/configuring-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/configuring_unleash",
+ "destination": "/using-unleash/deploy/configuring-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/configuring-unleash-v3",
+ "destination": "/using-unleash/deploy/configuring-unleash-v3",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/configuring_unleash_v3",
+ "destination": "/using-unleash/deploy/configuring-unleash-v3",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/database-setup",
+ "destination": "/using-unleash/deploy/database-setup",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/database-setup",
+ "destination": "/using-unleash/deploy/database-setup",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/database-backup",
+ "destination": "/using-unleash/deploy/database-backup",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/database-backup",
+ "destination": "/using-unleash/deploy/database-backup",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/email-service",
+ "destination": "/using-unleash/deploy/email-service",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/email",
+ "destination": "/using-unleash/deploy/email-service",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/google-auth-hook",
+ "destination": "/using-unleash/deploy/google-auth-hook",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/google_auth",
+ "destination": "/using-unleash/deploy/google-auth-hook",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/migration_guide",
+ "destination": "/using-unleash/deploy/upgrading-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/migration-guide",
+ "destination": "/using-unleash/deploy/upgrading-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/securing-unleash",
+ "destination": "/using-unleash/deploy/securing-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/securing_unleash",
+ "destination": "/using-unleash/deploy/securing-unleash",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/import-export",
+ "destination": "/how-to/how-to-import-export",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/import_export",
+ "destination": "/how-to/how-to-import-export",
+ "permanent": true
+ },
+ {
+ "source": "/reference/deploy/environment-import-export",
+ "destination": "/how-to/how-to-environment-import-export",
+ "permanent": true
+ },
+ {
+ "source": "/deploy/environment-import-export",
+ "destination": "/how-to/how-to-environment-import-export",
+ "permanent": true
+ }
]
-}
-
-
+}
\ No newline at end of file
diff --git a/website/yarn.lock b/website/yarn.lock
index ccda46d5afcf..5de5d54ff37d 100644
--- a/website/yarn.lock
+++ b/website/yarn.lock
@@ -3220,10 +3220,10 @@
redux-thunk "^2.4.2"
reselect "^4.1.7"
-"@remix-run/router@1.14.1":
- version "1.14.1"
- resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.14.1.tgz#6d2dd03d52e604279c38911afc1079d58c50a755"
- integrity sha512-Qg4DMQsfPNAs88rb2xkdk03N3bjK4jgX5fR24eHCTR9q6PrhZQZ4UJBPzCHJkIpTRN1UKxx2DzjZmnC+7Lj0Ow==
+"@remix-run/router@1.14.2":
+ version "1.14.2"
+ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.14.2.tgz#4d58f59908d9197ba3179310077f25c88e49ed17"
+ integrity sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==
"@sideway/address@^4.1.3":
version "4.1.4"
@@ -9757,12 +9757,12 @@ react-router@5.3.4, react-router@^5.3.3:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
-react-router@6.21.1:
- version "6.21.1"
- resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.21.1.tgz#8db7ee8d7cfc36513c9a66b44e0897208c33be34"
- integrity sha512-W0l13YlMTm1YrpVIOpjCADJqEUpz1vm+CMo47RuFX4Ftegwm6KOYsL5G3eiE52jnJpKvzm6uB/vTKTPKM8dmkA==
+react-router@6.21.2:
+ version "6.21.2"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.21.2.tgz#8820906c609ae7e4e8f926cc8eb5ce161428b956"
+ integrity sha512-jJcgiwDsnaHIeC+IN7atO0XiSRCrOsQAHHbChtJxmgqG2IaYQXSnhqGb5vk2CU/wBQA12Zt+TkbuJjIn65gzbA==
dependencies:
- "@remix-run/router" "1.14.1"
+ "@remix-run/router" "1.14.2"
react-textarea-autosize@^8.3.2:
version "8.4.0"