mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
feat: expose lifecycle stage in project overview search (#7017)
This commit is contained in:
parent
28a7797aea
commit
97d702afeb
@ -44,6 +44,7 @@ import { InactiveUsersStore } from '../users/inactive/inactive-users-store';
|
|||||||
import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store';
|
import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store';
|
||||||
import { SegmentReadModel } from '../features/segment/segment-read-model';
|
import { SegmentReadModel } from '../features/segment/segment-read-model';
|
||||||
import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model';
|
import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model';
|
||||||
|
import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store';
|
||||||
|
|
||||||
export const createStores = (
|
export const createStores = (
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -145,11 +146,17 @@ export const createStores = (
|
|||||||
privateProjectStore: new PrivateProjectStore(db, getLogger),
|
privateProjectStore: new PrivateProjectStore(db, getLogger),
|
||||||
dependentFeaturesStore: new DependentFeaturesStore(db),
|
dependentFeaturesStore: new DependentFeaturesStore(db),
|
||||||
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
|
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
|
||||||
featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger),
|
featureSearchStore: new FeatureSearchStore(
|
||||||
|
db,
|
||||||
|
eventBus,
|
||||||
|
getLogger,
|
||||||
|
config.flagResolver,
|
||||||
|
),
|
||||||
inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger),
|
inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger),
|
||||||
trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger),
|
trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger),
|
||||||
segmentReadModel: new SegmentReadModel(db),
|
segmentReadModel: new SegmentReadModel(db),
|
||||||
projectOwnersReadModel: new ProjectOwnersReadModel(db),
|
projectOwnersReadModel: new ProjectOwnersReadModel(db),
|
||||||
|
featureLifecycleStore: new FeatureLifecycleStore(db),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ export const createFeatureSearchService =
|
|||||||
db,
|
db,
|
||||||
eventBus,
|
eventBus,
|
||||||
getLogger,
|
getLogger,
|
||||||
|
flagResolver,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new FeatureSearchService(
|
return new FeatureSearchService(
|
||||||
|
@ -7,6 +7,7 @@ import type {
|
|||||||
IFeatureOverview,
|
IFeatureOverview,
|
||||||
IFeatureSearchOverview,
|
IFeatureSearchOverview,
|
||||||
IFeatureSearchStore,
|
IFeatureSearchStore,
|
||||||
|
IFlagResolver,
|
||||||
ITag,
|
ITag,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
|
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
|
||||||
@ -40,9 +41,17 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
|
|
||||||
private readonly timer: Function;
|
private readonly timer: Function;
|
||||||
|
|
||||||
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
db: Db,
|
||||||
|
eventBus: EventEmitter,
|
||||||
|
getLogger: LogProvider,
|
||||||
|
flagResolver: IFlagResolver,
|
||||||
|
) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.logger = getLogger('feature-search-store.ts');
|
this.logger = getLogger('feature-search-store.ts');
|
||||||
|
this.flagResolver = flagResolver;
|
||||||
this.timer = (action) =>
|
this.timer = (action) =>
|
||||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||||
store: 'feature-search',
|
store: 'feature-search',
|
||||||
@ -65,6 +74,20 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLatestLifecycleStageQuery() {
|
||||||
|
return this.db('feature_lifecycles')
|
||||||
|
.select(
|
||||||
|
'feature as stage_feature',
|
||||||
|
'stage as latest_stage',
|
||||||
|
'created_at as entered_stage_at',
|
||||||
|
)
|
||||||
|
.distinctOn('stage_feature')
|
||||||
|
.orderBy([
|
||||||
|
'stage_feature',
|
||||||
|
{ column: 'entered_stage_at', order: 'desc' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
async searchFeatures(
|
async searchFeatures(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
@ -86,6 +109,9 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
const validatedSortOrder =
|
const validatedSortOrder =
|
||||||
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
|
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
|
||||||
|
|
||||||
|
const featureLifecycleEnabled =
|
||||||
|
this.flagResolver.isEnabled('featureLifecycle');
|
||||||
|
|
||||||
const finalQuery = this.db
|
const finalQuery = this.db
|
||||||
.with('ranked_features', (query) => {
|
.with('ranked_features', (query) => {
|
||||||
query.from('features');
|
query.from('features');
|
||||||
@ -235,6 +261,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
.select(selectColumns)
|
.select(selectColumns)
|
||||||
.denseRank('rank', this.db.raw(rankingSql));
|
.denseRank('rank', this.db.raw(rankingSql));
|
||||||
})
|
})
|
||||||
|
.with('lifecycle', this.getLatestLifecycleStageQuery())
|
||||||
.with(
|
.with(
|
||||||
'final_ranks',
|
'final_ranks',
|
||||||
this.db.raw(
|
this.db.raw(
|
||||||
@ -290,10 +317,20 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
.joinRaw('CROSS JOIN total_features')
|
.joinRaw('CROSS JOIN total_features')
|
||||||
.whereBetween('final_rank', [offset + 1, offset + limit])
|
.whereBetween('final_rank', [offset + 1, offset + limit])
|
||||||
.orderBy('final_rank');
|
.orderBy('final_rank');
|
||||||
|
if (featureLifecycleEnabled) {
|
||||||
|
finalQuery.leftJoin(
|
||||||
|
'lifecycle',
|
||||||
|
'ranked_features.feature_name',
|
||||||
|
'lifecycle.stage_feature',
|
||||||
|
);
|
||||||
|
}
|
||||||
const rows = await finalQuery;
|
const rows = await finalQuery;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
const overview = this.getAggregatedSearchData(rows);
|
const overview = this.getAggregatedSearchData(
|
||||||
|
rows,
|
||||||
|
featureLifecycleEnabled,
|
||||||
|
);
|
||||||
const features = sortEnvironments(overview);
|
const features = sortEnvironments(overview);
|
||||||
return {
|
return {
|
||||||
features,
|
features,
|
||||||
@ -349,7 +386,10 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
return rankingSql;
|
return rankingSql;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAggregatedSearchData(rows): IFeatureSearchOverview[] {
|
getAggregatedSearchData(
|
||||||
|
rows,
|
||||||
|
featureLifecycleEnabled: boolean,
|
||||||
|
): IFeatureSearchOverview[] {
|
||||||
const entriesMap: Map<string, IFeatureSearchOverview> = new Map();
|
const entriesMap: Map<string, IFeatureSearchOverview> = new Map();
|
||||||
const orderedEntries: IFeatureSearchOverview[] = [];
|
const orderedEntries: IFeatureSearchOverview[] = [];
|
||||||
|
|
||||||
@ -372,6 +412,14 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
environments: [],
|
environments: [],
|
||||||
segments: row.segment_name ? [row.segment_name] : [],
|
segments: row.segment_name ? [row.segment_name] : [],
|
||||||
};
|
};
|
||||||
|
if (featureLifecycleEnabled) {
|
||||||
|
entry.lifecycle = row.latest_stage
|
||||||
|
? {
|
||||||
|
stage: row.latest_stage,
|
||||||
|
enteredStageAt: row.entered_stage_at,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
entriesMap.set(row.feature_name, entry);
|
entriesMap.set(row.feature_name, entry);
|
||||||
orderedEntries.push(entry);
|
orderedEntries.push(entry);
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ beforeAll(async () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
flags: {
|
flags: {
|
||||||
strictSchemaValidation: true,
|
strictSchemaValidation: true,
|
||||||
|
featureLifecycle: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -924,7 +925,7 @@ test('should filter features by combined operators', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return environment usage metrics', async () => {
|
test('should return environment usage metrics and lifecycle', async () => {
|
||||||
await app.createFeature({
|
await app.createFeature({
|
||||||
name: 'my_feature_b',
|
name: 'my_feature_b',
|
||||||
createdAt: '2023-01-29T15:21:39.975Z',
|
createdAt: '2023-01-29T15:21:39.975Z',
|
||||||
@ -957,6 +958,13 @@ test('should return environment usage metrics', async () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await stores.featureLifecycleStore.insert([
|
||||||
|
{ feature: 'my_feature_b', stage: 'initial' },
|
||||||
|
]);
|
||||||
|
await stores.featureLifecycleStore.insert([
|
||||||
|
{ feature: 'my_feature_b', stage: 'pre-live' },
|
||||||
|
]);
|
||||||
|
|
||||||
const { body } = await searchFeatures({
|
const { body } = await searchFeatures({
|
||||||
query: 'my_feature_b',
|
query: 'my_feature_b',
|
||||||
});
|
});
|
||||||
@ -964,6 +972,7 @@ test('should return environment usage metrics', async () => {
|
|||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
name: 'my_feature_b',
|
name: 'my_feature_b',
|
||||||
|
lifecycle: { stage: 'pre-live' },
|
||||||
environments: [
|
environments: [
|
||||||
{
|
{
|
||||||
name: 'default',
|
name: 'default',
|
||||||
|
@ -143,6 +143,32 @@ export const featureSearchResponseSchema = {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'The list of feature tags',
|
description: 'The list of feature tags',
|
||||||
},
|
},
|
||||||
|
lifecycle: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Current lifecycle stage of the feature',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['stage', 'enteredStageAt'],
|
||||||
|
properties: {
|
||||||
|
stage: {
|
||||||
|
description: 'The name of the current lifecycle stage',
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'initial',
|
||||||
|
'pre-live',
|
||||||
|
'live',
|
||||||
|
'completed',
|
||||||
|
'archived',
|
||||||
|
],
|
||||||
|
example: 'initial',
|
||||||
|
},
|
||||||
|
enteredStageAt: {
|
||||||
|
description: 'When the feature entered this stage',
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
example: '2023-01-28T15:21:39.975Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
@ -237,6 +237,7 @@ export interface IFeatureOverview {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastSeenAt: Date;
|
lastSeenAt: Date;
|
||||||
environments: IEnvironmentOverview[];
|
environments: IEnvironmentOverview[];
|
||||||
|
lifecycle?: IFeatureLifecycleStage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IFeatureSearchOverview = Exclude<
|
export type IFeatureSearchOverview = Exclude<
|
||||||
|
@ -41,6 +41,7 @@ import type { IInactiveUsersStore } from '../users/inactive/types/inactive-users
|
|||||||
import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type';
|
import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type';
|
||||||
import { ISegmentReadModel } from '../features/segment/segment-read-model-type';
|
import { ISegmentReadModel } from '../features/segment/segment-read-model-type';
|
||||||
import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type';
|
import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type';
|
||||||
|
import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type';
|
||||||
|
|
||||||
export interface IUnleashStores {
|
export interface IUnleashStores {
|
||||||
accessStore: IAccessStore;
|
accessStore: IAccessStore;
|
||||||
@ -86,6 +87,7 @@ export interface IUnleashStores {
|
|||||||
trafficDataUsageStore: ITrafficDataUsageStore;
|
trafficDataUsageStore: ITrafficDataUsageStore;
|
||||||
segmentReadModel: ISegmentReadModel;
|
segmentReadModel: ISegmentReadModel;
|
||||||
projectOwnersReadModel: IProjectOwnersReadModel;
|
projectOwnersReadModel: IProjectOwnersReadModel;
|
||||||
|
featureLifecycleStore: IFeatureLifecycleStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -130,4 +132,5 @@ export {
|
|||||||
ITrafficDataUsageStore,
|
ITrafficDataUsageStore,
|
||||||
ISegmentReadModel,
|
ISegmentReadModel,
|
||||||
IProjectOwnersReadModel,
|
IProjectOwnersReadModel,
|
||||||
|
IFeatureLifecycleStore,
|
||||||
};
|
};
|
||||||
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -44,6 +44,7 @@ import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inac
|
|||||||
import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store';
|
import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store';
|
||||||
import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model';
|
import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model';
|
||||||
import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model';
|
import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model';
|
||||||
|
import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store';
|
||||||
|
|
||||||
const db = {
|
const db = {
|
||||||
select: () => ({
|
select: () => ({
|
||||||
@ -97,6 +98,7 @@ const createStores: () => IUnleashStores = () => {
|
|||||||
trafficDataUsageStore: new FakeTrafficDataUsageStore(),
|
trafficDataUsageStore: new FakeTrafficDataUsageStore(),
|
||||||
segmentReadModel: new FakeSegmentReadModel(),
|
segmentReadModel: new FakeSegmentReadModel(),
|
||||||
projectOwnersReadModel: new FakeProjectOwnersReadModel(),
|
projectOwnersReadModel: new FakeProjectOwnersReadModel(),
|
||||||
|
featureLifecycleStore: new FakeFeatureLifecycleStore(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user