mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
feat: search includes feature last seen data last hour (#6677)
This commit is contained in:
parent
d22ecd0c73
commit
a2a9a84974
@ -4,8 +4,8 @@ import metricsHelper from '../../util/metrics-helper';
|
|||||||
import { DB_TIME } from '../../metric-events';
|
import { DB_TIME } from '../../metric-events';
|
||||||
import type { Logger, LogProvider } from '../../logger';
|
import type { Logger, LogProvider } from '../../logger';
|
||||||
import type {
|
import type {
|
||||||
IEnvironmentOverview,
|
|
||||||
IFeatureOverview,
|
IFeatureOverview,
|
||||||
|
IFeatureSearchOverview,
|
||||||
IFeatureSearchStore,
|
IFeatureSearchStore,
|
||||||
ITag,
|
ITag,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
@ -17,6 +17,7 @@ import type {
|
|||||||
IQueryParam,
|
IQueryParam,
|
||||||
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
import { applyGenericQueryParams, applySearchFilters } from './search-utils';
|
import { applyGenericQueryParams, applySearchFilters } from './search-utils';
|
||||||
|
import type { FeatureSearchEnvironmentSchema } from '../../openapi/spec/feature-search-environment-schema';
|
||||||
|
|
||||||
const sortEnvironments = (overview: IFeatureOverview[]) => {
|
const sortEnvironments = (overview: IFeatureOverview[]) => {
|
||||||
return overview.map((data: IFeatureOverview) => ({
|
return overview.map((data: IFeatureOverview) => ({
|
||||||
@ -49,7 +50,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getEnvironment(r: any): IEnvironmentOverview {
|
private static getEnvironment(r: any): FeatureSearchEnvironmentSchema {
|
||||||
return {
|
return {
|
||||||
name: r.environment,
|
name: r.environment,
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
@ -59,6 +60,8 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
lastSeenAt: r.env_last_seen_at,
|
lastSeenAt: r.env_last_seen_at,
|
||||||
hasStrategies: r.has_strategies,
|
hasStrategies: r.has_strategies,
|
||||||
hasEnabledStrategies: r.has_enabled_strategies,
|
hasEnabledStrategies: r.has_enabled_strategies,
|
||||||
|
yes: Number(r.yes) || 0,
|
||||||
|
no: Number(r.no) || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +90,55 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
.with('ranked_features', (query) => {
|
.with('ranked_features', (query) => {
|
||||||
query.from('features');
|
query.from('features');
|
||||||
|
|
||||||
|
let selectColumns = [
|
||||||
|
'features.name as feature_name',
|
||||||
|
'features.description as description',
|
||||||
|
'features.type as type',
|
||||||
|
'features.project as project',
|
||||||
|
'features.created_at as created_at',
|
||||||
|
'features.stale as stale',
|
||||||
|
'features.last_seen_at as last_seen_at',
|
||||||
|
'features.impression_data as impression_data',
|
||||||
|
'feature_environments.enabled as enabled',
|
||||||
|
'feature_environments.environment as environment',
|
||||||
|
'feature_environments.variants as variants',
|
||||||
|
'environments.type as environment_type',
|
||||||
|
'environments.sort_order as environment_sort_order',
|
||||||
|
'ft.tag_value as tag_value',
|
||||||
|
'ft.tag_type as tag_type',
|
||||||
|
'segments.name as segment_name',
|
||||||
|
'client_metrics_env.yes as yes',
|
||||||
|
'client_metrics_env.no as no',
|
||||||
|
] as (string | Raw<any> | Knex.QueryBuilder)[];
|
||||||
|
|
||||||
|
const lastSeenQuery = 'last_seen_at_metrics.last_seen_at';
|
||||||
|
selectColumns.push(`${lastSeenQuery} as env_last_seen_at`);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
query.leftJoin(`favorite_features`, function () {
|
||||||
|
this.on(
|
||||||
|
'favorite_features.feature',
|
||||||
|
'features.name',
|
||||||
|
).andOnVal('favorite_features.user_id', '=', userId);
|
||||||
|
});
|
||||||
|
selectColumns = [
|
||||||
|
...selectColumns,
|
||||||
|
this.db.raw(
|
||||||
|
'favorite_features.feature is not null as favorite',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectColumns = [
|
||||||
|
...selectColumns,
|
||||||
|
this.db.raw(
|
||||||
|
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
|
||||||
|
),
|
||||||
|
this.db.raw(
|
||||||
|
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
applyQueryParams(query, queryParams);
|
applyQueryParams(query, queryParams);
|
||||||
applySearchFilters(query, searchParams, [
|
applySearchFilters(query, searchParams, [
|
||||||
'features.name',
|
'features.name',
|
||||||
@ -144,7 +196,24 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
'segments',
|
'segments',
|
||||||
'feature_strategy_segment.segment_id',
|
'feature_strategy_segment.segment_id',
|
||||||
'segments.id',
|
'segments.id',
|
||||||
|
)
|
||||||
|
.leftJoin('client_metrics_env', (qb) => {
|
||||||
|
qb.on(
|
||||||
|
'client_metrics_env.environment',
|
||||||
|
'=',
|
||||||
|
'environments.name',
|
||||||
|
)
|
||||||
|
.andOn(
|
||||||
|
'client_metrics_env.feature_name',
|
||||||
|
'=',
|
||||||
|
'features.name',
|
||||||
|
)
|
||||||
|
.andOn(
|
||||||
|
'client_metrics_env.timestamp',
|
||||||
|
'>=',
|
||||||
|
this.db.raw("NOW() - INTERVAL '1 hour'"),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
query.leftJoin('last_seen_at_metrics', function () {
|
query.leftJoin('last_seen_at_metrics', function () {
|
||||||
this.on(
|
this.on(
|
||||||
@ -158,89 +227,12 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
let selectColumns = [
|
const rankingSql = this.buildRankingSql(
|
||||||
'features.name as feature_name',
|
favoritesFirst,
|
||||||
'features.description as description',
|
sortBy,
|
||||||
'features.type as type',
|
validatedSortOrder,
|
||||||
'features.project as project',
|
lastSeenQuery,
|
||||||
'features.created_at as created_at',
|
);
|
||||||
'features.stale as stale',
|
|
||||||
'features.last_seen_at as last_seen_at',
|
|
||||||
'features.impression_data as impression_data',
|
|
||||||
'feature_environments.enabled as enabled',
|
|
||||||
'feature_environments.environment as environment',
|
|
||||||
'feature_environments.variants as variants',
|
|
||||||
'environments.type as environment_type',
|
|
||||||
'environments.sort_order as environment_sort_order',
|
|
||||||
'ft.tag_value as tag_value',
|
|
||||||
'ft.tag_type as tag_type',
|
|
||||||
'segments.name as segment_name',
|
|
||||||
] as (string | Raw<any> | Knex.QueryBuilder)[];
|
|
||||||
|
|
||||||
const lastSeenQuery = 'last_seen_at_metrics.last_seen_at';
|
|
||||||
selectColumns.push(`${lastSeenQuery} as env_last_seen_at`);
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
query.leftJoin(`favorite_features`, function () {
|
|
||||||
this.on(
|
|
||||||
'favorite_features.feature',
|
|
||||||
'features.name',
|
|
||||||
).andOnVal('favorite_features.user_id', '=', userId);
|
|
||||||
});
|
|
||||||
selectColumns = [
|
|
||||||
...selectColumns,
|
|
||||||
this.db.raw(
|
|
||||||
'favorite_features.feature is not null as favorite',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
selectColumns = [
|
|
||||||
...selectColumns,
|
|
||||||
this.db.raw(
|
|
||||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
|
|
||||||
),
|
|
||||||
this.db.raw(
|
|
||||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const sortByMapping = {
|
|
||||||
name: 'features.name',
|
|
||||||
type: 'features.type',
|
|
||||||
stale: 'features.stale',
|
|
||||||
project: 'features.project',
|
|
||||||
};
|
|
||||||
|
|
||||||
let rankingSql = 'order by ';
|
|
||||||
if (favoritesFirst) {
|
|
||||||
rankingSql +=
|
|
||||||
'favorite_features.feature is not null desc, ';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy.startsWith('environment:')) {
|
|
||||||
const [, envName] = sortBy.split(':');
|
|
||||||
rankingSql += this.db
|
|
||||||
.raw(
|
|
||||||
`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`,
|
|
||||||
[envName],
|
|
||||||
)
|
|
||||||
.toString();
|
|
||||||
} else if (sortBy === 'lastSeenAt') {
|
|
||||||
rankingSql += `${this.db
|
|
||||||
.raw(
|
|
||||||
`coalesce(${lastSeenQuery}, features.last_seen_at) ${validatedSortOrder} nulls last`,
|
|
||||||
)
|
|
||||||
.toString()}, features.created_at asc, features.name asc`;
|
|
||||||
} else if (sortByMapping[sortBy]) {
|
|
||||||
rankingSql += `${this.db
|
|
||||||
.raw(`?? ${validatedSortOrder}`, [
|
|
||||||
sortByMapping[sortBy],
|
|
||||||
])
|
|
||||||
.toString()}, features.created_at asc, features.name asc`;
|
|
||||||
} else {
|
|
||||||
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
|
|
||||||
}
|
|
||||||
|
|
||||||
query
|
query
|
||||||
.select(selectColumns)
|
.select(selectColumns)
|
||||||
@ -282,9 +274,51 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getAggregatedSearchData(rows): IFeatureOverview[] {
|
private buildRankingSql(
|
||||||
const entriesMap: Map<string, IFeatureOverview> = new Map();
|
favoritesFirst: undefined | boolean,
|
||||||
const orderedEntries: IFeatureOverview[] = [];
|
sortBy: string,
|
||||||
|
validatedSortOrder: 'asc' | 'desc',
|
||||||
|
lastSeenQuery: string,
|
||||||
|
) {
|
||||||
|
const sortByMapping = {
|
||||||
|
name: 'features.name',
|
||||||
|
type: 'features.type',
|
||||||
|
stale: 'features.stale',
|
||||||
|
project: 'features.project',
|
||||||
|
};
|
||||||
|
|
||||||
|
let rankingSql = 'order by ';
|
||||||
|
if (favoritesFirst) {
|
||||||
|
rankingSql += 'favorite_features.feature is not null desc, ';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy.startsWith('environment:')) {
|
||||||
|
const [, envName] = sortBy.split(':');
|
||||||
|
rankingSql += this.db
|
||||||
|
.raw(
|
||||||
|
`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`,
|
||||||
|
[envName],
|
||||||
|
)
|
||||||
|
.toString();
|
||||||
|
} else if (sortBy === 'lastSeenAt') {
|
||||||
|
rankingSql += `${this.db
|
||||||
|
.raw(
|
||||||
|
`coalesce(${lastSeenQuery}, features.last_seen_at) ${validatedSortOrder} nulls last`,
|
||||||
|
)
|
||||||
|
.toString()}, features.created_at asc, features.name asc`;
|
||||||
|
} else if (sortByMapping[sortBy]) {
|
||||||
|
rankingSql += `${this.db
|
||||||
|
.raw(`?? ${validatedSortOrder}`, [sortByMapping[sortBy]])
|
||||||
|
.toString()}, features.created_at asc, features.name asc`;
|
||||||
|
} else {
|
||||||
|
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
|
||||||
|
}
|
||||||
|
return rankingSql;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAggregatedSearchData(rows): IFeatureSearchOverview[] {
|
||||||
|
const entriesMap: Map<string, IFeatureSearchOverview> = new Map();
|
||||||
|
const orderedEntries: IFeatureSearchOverview[] = [];
|
||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
let entry = entriesMap.get(row.feature_name);
|
let entry = entriesMap.get(row.feature_name);
|
||||||
|
@ -923,3 +923,57 @@ test('should filter features by combined operators', async () => {
|
|||||||
features: [{ name: 'my_feature_a' }],
|
features: [{ name: 'my_feature_a' }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return environment usage metrics', async () => {
|
||||||
|
await app.createFeature({
|
||||||
|
name: 'my_feature_b',
|
||||||
|
createdAt: '2023-01-29T15:21:39.975Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await stores.clientMetricsStoreV2.batchInsertMetrics([
|
||||||
|
{
|
||||||
|
featureName: `my_feature_b`,
|
||||||
|
appName: `web`,
|
||||||
|
environment: 'development',
|
||||||
|
timestamp: new Date(),
|
||||||
|
yes: 5,
|
||||||
|
no: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
featureName: `my_feature_b`,
|
||||||
|
appName: `web`,
|
||||||
|
environment: 'production',
|
||||||
|
timestamp: new Date(),
|
||||||
|
yes: 2,
|
||||||
|
no: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { body } = await searchFeatures({
|
||||||
|
query: 'my_feature_b',
|
||||||
|
});
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
name: 'my_feature_b',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
yes: 0,
|
||||||
|
no: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'development',
|
||||||
|
yes: 5,
|
||||||
|
no: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
yes: 2,
|
||||||
|
no: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
46
src/lib/openapi/spec/feature-search-environment-schema.ts
Normal file
46
src/lib/openapi/spec/feature-search-environment-schema.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { constraintSchema } from './constraint-schema';
|
||||||
|
import { parametersSchema } from './parameters-schema';
|
||||||
|
import { featureStrategySchema } from './feature-strategy-schema';
|
||||||
|
import { variantSchema } from './variant-schema';
|
||||||
|
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||||
|
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||||
|
|
||||||
|
export const featureSearchEnvironmentSchema = {
|
||||||
|
$id: '#/components/schemas/featureSearchEnvironmentSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['name', 'enabled'],
|
||||||
|
description: 'A detailed description of the feature environment',
|
||||||
|
properties: {
|
||||||
|
...featureEnvironmentSchema.properties,
|
||||||
|
yes: {
|
||||||
|
description:
|
||||||
|
'How many times the toggle evaluated to true in last hour bucket',
|
||||||
|
type: 'integer',
|
||||||
|
example: 974,
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
no: {
|
||||||
|
description:
|
||||||
|
'How many times the toggle evaluated to false in last hour bucket',
|
||||||
|
type: 'integer',
|
||||||
|
example: 50,
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
constraintSchema,
|
||||||
|
parametersSchema,
|
||||||
|
featureStrategySchema,
|
||||||
|
strategyVariantSchema,
|
||||||
|
featureEnvironmentSchema,
|
||||||
|
variantSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type FeatureSearchEnvironmentSchema = FromSchema<
|
||||||
|
typeof featureSearchEnvironmentSchema
|
||||||
|
>;
|
@ -5,8 +5,8 @@ import { overrideSchema } from './override-schema';
|
|||||||
import { parametersSchema } from './parameters-schema';
|
import { parametersSchema } from './parameters-schema';
|
||||||
import { featureStrategySchema } from './feature-strategy-schema';
|
import { featureStrategySchema } from './feature-strategy-schema';
|
||||||
import { tagSchema } from './tag-schema';
|
import { tagSchema } from './tag-schema';
|
||||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
|
||||||
import { strategyVariantSchema } from './strategy-variant-schema';
|
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||||
|
import { featureSearchEnvironmentSchema } from './feature-search-environment-schema';
|
||||||
|
|
||||||
export const featureSearchResponseSchema = {
|
export const featureSearchResponseSchema = {
|
||||||
$id: '#/components/schemas/featureSearchResponseSchema',
|
$id: '#/components/schemas/featureSearchResponseSchema',
|
||||||
@ -92,7 +92,7 @@ export const featureSearchResponseSchema = {
|
|||||||
environments: {
|
environments: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
$ref: '#/components/schemas/featureEnvironmentSchema',
|
$ref: '#/components/schemas/featureSearchEnvironmentSchema',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
'The list of environments where the feature can be used',
|
'The list of environments where the feature can be used',
|
||||||
@ -174,7 +174,7 @@ export const featureSearchResponseSchema = {
|
|||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
constraintSchema,
|
constraintSchema,
|
||||||
featureEnvironmentSchema,
|
featureSearchEnvironmentSchema,
|
||||||
featureStrategySchema,
|
featureStrategySchema,
|
||||||
strategyVariantSchema,
|
strategyVariantSchema,
|
||||||
overrideSchema,
|
overrideSchema,
|
||||||
|
@ -77,6 +77,7 @@ export * from './feature-environment-schema';
|
|||||||
export * from './feature-events-schema';
|
export * from './feature-events-schema';
|
||||||
export * from './feature-metrics-schema';
|
export * from './feature-metrics-schema';
|
||||||
export * from './feature-schema';
|
export * from './feature-schema';
|
||||||
|
export * from './feature-search-environment-schema';
|
||||||
export * from './feature-search-response-schema';
|
export * from './feature-search-response-schema';
|
||||||
export * from './feature-strategy-schema';
|
export * from './feature-strategy-schema';
|
||||||
export * from './feature-strategy-segment-schema';
|
export * from './feature-strategy-segment-schema';
|
||||||
|
@ -4,10 +4,10 @@ import { variantSchema } from './variant-schema';
|
|||||||
import { overrideSchema } from './override-schema';
|
import { overrideSchema } from './override-schema';
|
||||||
import { featureStrategySchema } from './feature-strategy-schema';
|
import { featureStrategySchema } from './feature-strategy-schema';
|
||||||
import { constraintSchema } from './constraint-schema';
|
import { constraintSchema } from './constraint-schema';
|
||||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
|
||||||
import { strategyVariantSchema } from './strategy-variant-schema';
|
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||||
import { tagSchema } from './tag-schema';
|
import { tagSchema } from './tag-schema';
|
||||||
import { featureSearchResponseSchema } from './feature-search-response-schema';
|
import { featureSearchResponseSchema } from './feature-search-response-schema';
|
||||||
|
import { featureSearchEnvironmentSchema } from './feature-search-environment-schema';
|
||||||
|
|
||||||
export const searchFeaturesSchema = {
|
export const searchFeaturesSchema = {
|
||||||
$id: '#/components/schemas/searchFeaturesSchema',
|
$id: '#/components/schemas/searchFeaturesSchema',
|
||||||
@ -33,9 +33,9 @@ export const searchFeaturesSchema = {
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
featureSearchEnvironmentSchema,
|
||||||
featureSearchResponseSchema,
|
featureSearchResponseSchema,
|
||||||
constraintSchema,
|
constraintSchema,
|
||||||
featureEnvironmentSchema,
|
|
||||||
featureStrategySchema,
|
featureStrategySchema,
|
||||||
strategyVariantSchema,
|
strategyVariantSchema,
|
||||||
overrideSchema,
|
overrideSchema,
|
||||||
|
@ -6,6 +6,7 @@ import type { ALL_OPERATORS } from '../util';
|
|||||||
import type { IProjectStats } from '../features/project/project-service';
|
import type { IProjectStats } from '../features/project/project-service';
|
||||||
import type { CreateFeatureStrategySchema } from '../openapi';
|
import type { CreateFeatureStrategySchema } from '../openapi';
|
||||||
import type { ProjectEnvironment } from '../features/project/project-store-type';
|
import type { ProjectEnvironment } from '../features/project/project-store-type';
|
||||||
|
import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema';
|
||||||
|
|
||||||
export type Operator = (typeof ALL_OPERATORS)[number];
|
export type Operator = (typeof ALL_OPERATORS)[number];
|
||||||
|
|
||||||
@ -218,6 +219,13 @@ export interface IFeatureOverview {
|
|||||||
environments: IEnvironmentOverview[];
|
environments: IEnvironmentOverview[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IFeatureSearchOverview = Exclude<
|
||||||
|
IFeatureOverview,
|
||||||
|
'environments'
|
||||||
|
> & {
|
||||||
|
environments: FeatureSearchEnvironmentSchema[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface IFeatureTypeCount {
|
export interface IFeatureTypeCount {
|
||||||
type: string;
|
type: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
Loading…
Reference in New Issue
Block a user