1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

Merge branch 'main' into quickstart-update

This commit is contained in:
melindafekete 2025-07-04 14:44:48 +02:00
commit 8328e83d11
No known key found for this signature in database
15 changed files with 119 additions and 109 deletions

View File

@ -1,18 +0,0 @@
import type { FC } from 'react';
import { NameWithChangeInfo } from './NameWithChangeInfo/NameWithChangeInfo.tsx';
type ChangeSegmentNameProps = {
name: string;
previousName?: string;
};
export const ChangeSegmentName: FC<ChangeSegmentNameProps> = ({
name,
previousName,
}) => {
return (
<span>
<NameWithChangeInfo newName={name} previousName={previousName} />
</span>
);
};

View File

@ -1,30 +0,0 @@
import type { FC } from 'react';
import { formatStrategyName } from 'utils/strategyNames';
import { styled } from '@mui/material';
import { textTruncated } from 'themes/themeStyles';
import { NameWithChangeInfo } from './NameWithChangeInfo/NameWithChangeInfo.tsx';
import { Truncator } from 'component/common/Truncator/Truncator.tsx';
type ChangeStrategyNameProps = {
name: string;
title?: string;
previousTitle?: string;
};
const Truncated = styled('span')(() => ({
...textTruncated,
maxWidth: 500,
}));
export const ChangeStrategyName: FC<ChangeStrategyNameProps> = ({
name,
title,
previousTitle,
}) => {
return (
<Truncated>
<Truncator component='span'>{formatStrategyName(name)}</Truncator>
<NameWithChangeInfo newName={title} previousName={previousTitle} />
</Truncated>
);
};

View File

@ -109,7 +109,7 @@ export const EnvironmentStrategyExecutionOrder = ({
</NewChangeItemInfo>
<div>
<TabList>
<Tab>Change</Tab>
<Tab>View change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}

View File

@ -6,7 +6,6 @@ import { textTruncated } from 'themes/themeStyles';
const Truncated = styled('span')(() => ({
...textTruncated,
maxWidth: 500,
display: 'block',
}));
const NewName = styled(Typography)<TypographyProps>({

View File

@ -89,7 +89,7 @@ const StartMilestone: FC<{
</ChangeItemInfo>
<div>
<TabList>
<Tab>Change</Tab>
<Tab>View change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
@ -202,7 +202,7 @@ const AddReleasePlan: FC<{
</ChangeItemInfo>
<div>
<TabList>
<Tab>Changes</Tab>
<Tab>View change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}

View File

@ -17,7 +17,7 @@ import {
Deleted,
} from './Change.styles.tsx';
import { SegmentDiff } from './SegmentDiff.tsx';
import { ChangeSegmentName } from './ChangeSegmentName.tsx';
import { NameWithChangeInfo } from './NameWithChangeInfo/NameWithChangeInfo.tsx';
const ActionsContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -57,7 +57,7 @@ export const SegmentChangeDetails: FC<{
const actionsWithTabs = (
<ActionsContainer>
<TabList>
<Tab>Change</Tab>
<Tab>View change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}
@ -72,8 +72,8 @@ export const SegmentChangeDetails: FC<{
<ChangeItemWrapper>
<ChangeItemInfo>
<Deleted>Deleting segment</Deleted>
<ChangeSegmentName
name={change.payload.name}
<NameWithChangeInfo
newName={change.payload.name}
previousName={previousName}
/>
</ChangeItemInfo>
@ -102,8 +102,8 @@ export const SegmentChangeDetails: FC<{
<ChangeItemWrapper>
<ChangeItemInfo>
<Action>Editing segment</Action>
<ChangeSegmentName
name={change.payload.name}
<NameWithChangeInfo
newName={change.payload.name}
previousName={previousName}
/>
</ChangeItemInfo>

View File

@ -243,7 +243,6 @@ test('Deleting strategy before change request is applied diffs against current s
);
await screen.findByText('Deleting strategy');
await screen.findByText('Gradual rollout');
await screen.findByText('current_title');
await screen.findByText('current_variant');
@ -299,7 +298,6 @@ test('Deleting strategy after change request is applied diffs against the snapsh
);
await screen.findByText('Deleting strategy');
await screen.findByText('Gradual rollout');
await screen.findByText('snapshot_title');
expect(screen.queryByText('current_title')).not.toBeInTheDocument();

View File

@ -18,7 +18,6 @@ import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureV
import { ChangeOverwriteWarning } from './ChangeOverwriteWarning/ChangeOverwriteWarning.tsx';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { Tab, TabList, TabPanel, Tabs } from './ChangeTabComponents.tsx';
import { ChangeStrategyName } from './ChangeStrategyName.tsx';
import { StrategyDiff } from './StrategyDiff.tsx';
import {
Action,
@ -27,6 +26,7 @@ import {
ChangeItemWrapper,
Deleted,
} from './Change.styles.tsx';
import { NameWithChangeInfo } from './NameWithChangeInfo/NameWithChangeInfo.tsx';
const StyledBox: FC<{ children?: React.ReactNode }> = styled(Box)(
({ theme }) => ({
@ -103,10 +103,6 @@ const DeleteStrategy: FC<{
currentStrategy: IFeatureStrategy | undefined;
actions?: ReactNode;
}> = ({ change, changeRequestState, currentStrategy, actions }) => {
const name =
changeRequestState === 'Applied'
? change.payload?.snapshot?.name
: currentStrategy?.name;
const title =
changeRequestState === 'Applied'
? change.payload?.snapshot?.title
@ -121,7 +117,10 @@ const DeleteStrategy: FC<{
<ChangeItemWrapper>
<ChangeItemInfo>
<Deleted>Deleting strategy</Deleted>
<ChangeStrategyName name={name || ''} title={title} />
<NameWithChangeInfo
newName={title}
previousName={referenceStrategy?.title}
/>
</ChangeItemInfo>
{actions}
</ChangeItemWrapper>
@ -175,10 +174,9 @@ const UpdateStrategy: FC<{
wasDisabled={currentStrategy?.disabled}
willBeDisabled={change.payload?.disabled}
/>
<ChangeStrategyName
name={change.payload.name}
title={change.payload.title}
previousTitle={previousTitle}
<NameWithChangeInfo
newName={change.payload.title}
previousName={previousTitle}
/>
</ChangeItemInfo>
{actions}
@ -248,10 +246,7 @@ const AddStrategy: FC<{
<AddedStrategy disabled={change.payload?.disabled}>
Adding {isDefaultChange && 'default'} strategy
</AddedStrategy>
<ChangeStrategyName
name={change.payload.name}
title={change.payload.title}
/>
<NameWithChangeInfo newName={change.payload.title} />
<DisabledEnabledState
disabled
show={change.payload?.disabled === true}
@ -321,7 +316,7 @@ export const StrategyChange: FC<{
const actionsWithTabs = (
<ActionsContainer>
<TabList>
<Tab>Change</Tab>
<Tab>View change</Tab>
<Tab>View diff</Tab>
</TabList>
{actions}

View File

@ -1,6 +1,6 @@
import { useState, type VFC } from 'react';
import { Box, Button, styled, Typography } from '@mui/material';
import { DynamicSidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import CheckCircle from '@mui/icons-material/CheckCircle';
@ -22,7 +22,6 @@ interface IChangeRequestSidebarProps {
const StyledPageContent = styled(PageContent)(({ theme }) => ({
height: '100vh',
overflow: 'auto',
minWidth: '50vw',
padding: theme.spacing(6),
[theme.breakpoints.down('md')]: {
padding: theme.spacing(4, 2),
@ -106,11 +105,7 @@ export const ChangeRequestSidebar: VFC<IChangeRequestSidebarProps> = ({
if (!loading && !data) {
return (
<DynamicSidebarModal
open={open}
onClose={onClose}
label='Review changes'
>
<SidebarModal open={open} onClose={onClose} label='Review changes'>
<StyledPageContent
disableBorder={true}
header={<PageHeader titleElement='Review your changes' />}
@ -119,16 +114,12 @@ export const ChangeRequestSidebar: VFC<IChangeRequestSidebarProps> = ({
{/* FIXME: empty state */}
<BackButton onClick={onClose}>Close</BackButton>
</StyledPageContent>
</DynamicSidebarModal>
</SidebarModal>
);
}
return (
<DynamicSidebarModal
open={open}
onClose={onClose}
label='Review changes'
>
<SidebarModal open={open} onClose={onClose} label='Review changes'>
<StyledPageContent
disableBorder={true}
header={<ReviewChangesHeader />}
@ -159,6 +150,6 @@ export const ChangeRequestSidebar: VFC<IChangeRequestSidebarProps> = ({
</ChangeRequestPlausibleProvider>
))}
</StyledPageContent>
</DynamicSidebarModal>
</SidebarModal>
);
};

View File

@ -154,6 +154,27 @@ describe('validators', () => {
]);
},
);
test.each(multipleValueOperators)(
'multi-value operator %s should reject fully duplicate inputs and accept new values',
(operator) => {
const initial: IConstraint = {
contextName: 'context-field',
operator: operator,
values: ['a', 'b'],
};
const { result } = renderHook(() =>
useEditableConstraint(initial, () => {}),
);
checkValidator(result.current.validator, [
['a', false],
[['a', 'c'], true],
[['a', 'b'], false],
]);
},
);
});
describe('legal values', () => {

View File

@ -4,6 +4,7 @@ import type { IConstraint } from 'interfaces/strategy';
import {
type EditableConstraint,
fromIConstraint,
isMultiValueConstraint,
isSingleValueConstraint,
toIConstraint,
} from './editable-constraint-type.ts';
@ -16,8 +17,8 @@ import {
type ConstraintUpdateAction,
} from './constraint-reducer.ts';
import {
type ConstraintValidationResult,
constraintValidator,
type ConstraintValidationResult,
} from './constraint-validator.ts';
import {
getDeletedLegalValues,
@ -76,7 +77,20 @@ export const useEditableConstraint = (
[JSON.stringify(context), localConstraint.contextName],
);
const validator = constraintValidator(localConstraint.operator);
const baseValidator = constraintValidator(localConstraint.operator);
const validator = (...values: string[]) => {
if (
isMultiValueConstraint(localConstraint) &&
values.every((value) => localConstraint.values.has(value))
) {
if (values.length === 1) {
return [false, `${values[0]} is already added.`];
}
return [false, `All the values are already added`];
}
return baseValidator(...values);
};
useEffect(() => {
if (
@ -104,7 +118,7 @@ export const useEditableConstraint = (
isSingleValueConstraint(localConstraint)
) {
return getInvalidLegalValues(
(value) => validator(value)[0],
(value) => baseValidator(value)[0],
contextDefinition.legalValues,
);
}

View File

@ -103,10 +103,7 @@ export default async function getApp(
if (config.enableOAS && services.openApiService) {
services.openApiService.useDocs(app);
}
// Support CORS preflight requests for the frontend endpoints.
// Preflight requests should not have Authorization headers,
// so this must be handled before the API token middleware.
app.options(
app.use(
`${baseUriPath}/api/frontend*`,
corsOriginMiddleware(services, config),
);

View File

@ -28,6 +28,8 @@ import type { ProjectActivitySchema } from '../../openapi/index.js';
import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type.js';
import { applyGenericQueryParams } from '../feature-search/search-utils.js';
import type { ITag } from '../../tags/index.js';
import metricsHelper from '../../util/metrics-helper.js';
import { DB_TIME } from '../../metric-events.js';
const EVENT_COLUMNS = [
'id',
@ -113,26 +115,38 @@ export class EventStore implements IEventStore {
private logger: Logger;
private metricTimer: Function;
// a new DB has to be injected per transaction
constructor(db: Db, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('event-store');
this.metricTimer = (action) =>
metricsHelper.wrapTimer(this.eventEmitter, DB_TIME, {
store: 'event',
action,
});
}
async store(event: IBaseEvent): Promise<void> {
const stopTimer = this.metricTimer('store');
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}`);
} finally {
stopTimer();
}
}
async count(): Promise<number> {
const stopTimer = this.metricTimer('count');
const count = await this.db(TABLE)
.count<Record<string, number>>()
.first();
stopTimer();
if (!count) {
return 0;
}
@ -147,8 +161,10 @@ export class EventStore implements IEventStore {
queryParams: IQueryParam[],
query?: IEventSearchParams['query'],
): Promise<number> {
const stopTimer = this.metricTimer('searchEventsCount');
const searchQuery = this.buildSearchQuery(queryParams, query);
const count = await searchQuery.count().first();
stopTimer();
if (!count) {
return 0;
}
@ -160,6 +176,7 @@ export class EventStore implements IEventStore {
}
async batchStore(events: IBaseEvent[]): Promise<void> {
const stopTimer = this.metricTimer('batchStore');
try {
await this.db(TABLE).insert(
events.map((event) => this.eventToDbRow(event)),
@ -169,10 +186,13 @@ export class EventStore implements IEventStore {
`Failed to store events: ${JSON.stringify(events)}`,
error,
);
} finally {
stopTimer();
}
}
async getMaxRevisionId(largerThan: number = 0): Promise<number> {
const stopTimer = this.metricTimer('getMaxRevisionId');
const row = await this.db(TABLE)
.max('id')
.where((builder) =>
@ -193,10 +213,12 @@ export class EventStore implements IEventStore {
)
.andWhere('id', '>=', largerThan)
.first();
stopTimer();
return row?.max ?? 0;
}
async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
const stopTimer = this.metricTimer('getRevisionRange');
const query = this.db
.select(EVENT_COLUMNS)
.from(TABLE)
@ -246,6 +268,7 @@ export class EventStore implements IEventStore {
}
async query(operations: IQueryOperations[]): Promise<IEvent[]> {
const stopTimer = this.metricTimer('query');
try {
let query: Knex.QueryBuilder = this.select();
@ -271,10 +294,13 @@ export class EventStore implements IEventStore {
return rows.map(this.rowToEvent);
} catch (e) {
return [];
} finally {
stopTimer();
}
}
async queryCount(operations: IQueryOperations[]): Promise<number> {
const stopTimer = this.metricTimer('queryCount');
try {
let query: Knex.QueryBuilder = this.db.count().from(TABLE);
@ -300,6 +326,8 @@ export class EventStore implements IEventStore {
return Number.parseInt(queryResult.count || 0);
} catch (e) {
return 0;
} finally {
stopTimer();
}
}
@ -355,6 +383,7 @@ export class EventStore implements IEventStore {
}
async getEvents(query?: Object): Promise<IEvent[]> {
const stopTimer = this.metricTimer('getEvents');
try {
let qB = this.db
.select(EVENT_COLUMNS)
@ -371,6 +400,8 @@ export class EventStore implements IEventStore {
return rows.map(this.rowToEvent);
} catch (err) {
return [];
} finally {
stopTimer();
}
}
@ -379,6 +410,7 @@ export class EventStore implements IEventStore {
queryParams: IQueryParam[],
options?: { withIp?: boolean },
): Promise<IEvent[]> {
const stopTimer = this.metricTimer('searchEvents');
const query = this.buildSearchQuery(queryParams, params.query)
.select(options?.withIp ? [...EVENT_COLUMNS, 'ip'] : EVENT_COLUMNS)
.orderBy([
@ -396,6 +428,8 @@ export class EventStore implements IEventStore {
);
} catch (err) {
return [];
} finally {
stopTimer();
}
}
@ -420,6 +454,7 @@ export class EventStore implements IEventStore {
}
async getEventCreators(): Promise<Array<{ id: number; name: string }>> {
const stopTimer = this.metricTimer('getEventCreators');
const query = this.db('events')
.distinctOn('events.created_by_user_id')
.leftJoin('users', 'users.id', '=', 'events.created_by_user_id')
@ -437,6 +472,7 @@ export class EventStore implements IEventStore {
]);
const result = await query;
stopTimer();
return result
.filter((row: any) => row.name || row.username || row.email)
.map((row: any) => ({
@ -448,6 +484,7 @@ export class EventStore implements IEventStore {
async getProjectRecentEventActivity(
project: string,
): Promise<ProjectActivitySchema> {
const stopTimer = this.metricTimer('getProjectRecentEventActivity');
const result = await this.db('events')
.select(
this.db.raw("TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date"),
@ -462,6 +499,7 @@ export class EventStore implements IEventStore {
.groupBy(this.db.raw("TO_CHAR(created_at::date, 'YYYY-MM-DD')"))
.orderBy('date', 'asc');
stopTimer();
return result.map((row) => ({
date: row.date,
count: Number(row.count),
@ -531,10 +569,12 @@ export class EventStore implements IEventStore {
}
async setUnannouncedToAnnounced(): Promise<IEvent[]> {
const stopTimer = this.metricTimer('setUnannouncedToAnnounced');
const rows = await this.db(TABLE)
.update({ announced: true })
.where('announced', false)
.returning(EVENT_COLUMNS);
stopTimer();
return rows.map(this.rowToEvent);
}
@ -545,6 +585,7 @@ export class EventStore implements IEventStore {
}
async setCreatedByUserId(batchSize: number): Promise<number | undefined> {
const stopTimer = this.metricTimer('setCreatedByUserId');
const API_TOKEN_TABLE = 'api_tokens';
const toUpdate = await this.db(`${TABLE} as e`)
@ -592,6 +633,7 @@ export class EventStore implements IEventStore {
});
await Promise.all(updatePromises);
stopTimer();
return toUpdate.length;
}
}

View File

@ -19,7 +19,6 @@ import {
} from '../../openapi/index.js';
import type { Context } from 'unleash-client';
import { enrichContextWithIp } from './index.js';
import { corsOriginMiddleware } from '../../middleware/index.js';
import NotImplementedError from '../../error/not-implemented-error.js';
import rateLimit from 'express-rate-limit';
import { minutesToMilliseconds } from 'date-fns';
@ -65,10 +64,6 @@ export default class FrontendAPIController extends Controller {
functionName,
});
// Support CORS requests for the frontend endpoints.
// Preflight requests are handled in `app.ts`.
this.app.use(corsOriginMiddleware(services, config));
this.route({
method: 'get',
path: '',

View File

@ -8,21 +8,27 @@ import {
const requestLogger: (config: IUnleashConfig) => RequestHandler = (config) => {
const logger = config.getLogger('HTTP');
const enable = config.server.enableRequestLogger;
const requestLoggerEnabled = config.server.enableRequestLogger;
const impactMetrics = config.flagResolver.impactMetrics;
return (req, res, next) => {
if (enable) {
res.on('finish', () => {
const { pathname } = url.parse(req.originalUrl);
const impactMetricsEnabled =
config.flagResolver.isEnabled('impactMetrics');
res.on('finish', () => {
if (impactMetricsEnabled && impactMetrics) {
if (res.statusCode >= 400 && res.statusCode < 500) {
impactMetrics?.incrementCounter(CLIENT_ERROR_COUNT);
impactMetrics.incrementCounter(CLIENT_ERROR_COUNT);
}
if (res.statusCode >= 500) {
impactMetrics?.incrementCounter(SERVER_ERROR_COUNT);
impactMetrics.incrementCounter(SERVER_ERROR_COUNT);
}
}
if (requestLoggerEnabled) {
const { pathname } = url.parse(req.originalUrl);
logger.info(`${res.statusCode} ${req.method} ${pathname}`);
});
}
}
});
next();
};
};