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

refactor: remove deprecated GET archive features endpoint (#9924)

https://linear.app/unleash/issue/2-3366/remove-get-apiadminarchivefeatures-deprecated-in-4100

Removes GET `/api/admin/archive/features` which was deprecated in v4.10.
Also cleans up related code.

May include some slight scouting.

**P.S.** Should we merge this into main, or is there a `v7` branch we
should be targeting instead?
This commit is contained in:
Nuno Góis 2025-05-13 11:45:03 +01:00 committed by GitHub
parent 410142cb42
commit 42b6fc810e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 46 additions and 302 deletions

View File

@ -17,7 +17,6 @@ import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search'; import { Search } from 'component/common/Search/Search';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell'; import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell'; import { ArchivedFeatureActionCell } from 'component/archive/ArchiveTable/ArchivedFeatureActionCell/ArchivedFeatureActionCell';
import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable'; import { featuresPlaceholder } from 'component/feature/FeatureToggleList/FeatureToggleListTable';
import theme from 'themes/theme'; import theme from 'themes/theme';
@ -32,7 +31,6 @@ import { RowSelectCell } from '../../project/Project/ProjectFeatureToggles/RowSe
import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/BatchSelectionActionsBar'; import { BatchSelectionActionsBar } from '../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ArchiveBatchActions } from './ArchiveBatchActions'; import { ArchiveBatchActions } from './ArchiveBatchActions';
import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; import { FeatureEnvironmentSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm'; import { ArchivedFeatureReviveConfirm } from './ArchivedFeatureActionCell/ArchivedFeatureReviveConfirm/ArchivedFeatureReviveConfirm';
export interface IFeaturesArchiveTableProps { export interface IFeaturesArchiveTableProps {
@ -46,7 +44,7 @@ export interface IFeaturesArchiveTableProps {
| SortingRule<string> | SortingRule<string>
| ((prev: SortingRule<string>) => SortingRule<string>), | ((prev: SortingRule<string>) => SortingRule<string>),
) => SortingRule<string>; ) => SortingRule<string>;
projectId?: string; projectId: string;
} }
export const ArchiveTable = ({ export const ArchiveTable = ({
@ -72,35 +70,27 @@ export const ArchiveTable = ({
searchParams.get('search') || '', searchParams.get('search') || '',
); );
const { uiConfig } = useUiConfig();
const columns = useMemo( const columns = useMemo(
() => [ () => [
...(projectId {
? [ id: 'Select',
{ Header: ({ getToggleAllRowsSelectedProps }: any) => (
id: 'Select', <Checkbox
Header: ({ getToggleAllRowsSelectedProps }: any) => ( data-testid='select_all_rows'
<Checkbox {...getToggleAllRowsSelectedProps()}
data-testid='select_all_rows' />
{...getToggleAllRowsSelectedProps()} ),
/> Cell: ({ row }: any) => (
), <RowSelectCell {...row?.getToggleRowSelectedProps?.()} />
Cell: ({ row }: any) => ( ),
<RowSelectCell maxWidth: 50,
{...row?.getToggleRowSelectedProps?.()} disableSortBy: true,
/> hideInMenu: true,
), },
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
]
: []),
{ {
Header: 'Seen', Header: 'Seen',
accessor: 'lastSeenAt', accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => { Cell: ({ row: { original: feature } }: any) => {
return <FeatureEnvironmentSeenCell feature={feature} />; return <FeatureEnvironmentSeenCell feature={feature} />;
}, },
align: 'center', align: 'center',
@ -139,24 +129,6 @@ export const ArchiveTable = ({
width: 150, width: 150,
Cell: FeatureArchivedCell, Cell: FeatureArchivedCell,
}, },
...(!projectId
? [
{
Header: 'Project ID',
accessor: 'project',
sortType: 'alphanumeric',
filterName: 'project',
searchable: true,
maxWidth: 170,
Cell: ({ value }: any) => (
<LinkCell
title={value}
to={`/projects/${value}`}
/>
),
},
]
: []),
{ {
Header: 'Actions', Header: 'Actions',
id: 'Actions', id: 'Actions',
@ -184,8 +156,7 @@ export const ArchiveTable = ({
searchable: true, searchable: true,
}, },
], ],
//eslint-disable-next-line [],
[projectId],
); );
const { const {
@ -270,7 +241,7 @@ export const ArchiveTable = ({
replace: true, replace: true,
}); });
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false }); setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
}, [loading, sortBy, searchValue]); // eslint-disable-line react-hooks/exhaustive-deps }, [loading, sortBy, searchValue]);
return ( return (
<> <>
@ -322,33 +293,28 @@ export const ArchiveTable = ({
/> />
<ArchivedFeatureDeleteConfirm <ArchivedFeatureDeleteConfirm
deletedFeatures={[deletedFeature?.name!]} deletedFeatures={[deletedFeature?.name!]}
projectId={projectId ?? deletedFeature?.project!} projectId={projectId}
open={deleteModalOpen} open={deleteModalOpen}
setOpen={setDeleteModalOpen} setOpen={setDeleteModalOpen}
refetch={refetch} refetch={refetch}
/> />
<ArchivedFeatureReviveConfirm <ArchivedFeatureReviveConfirm
revivedFeatures={[revivedFeature?.name!]} revivedFeatures={[revivedFeature?.name!]}
projectId={projectId ?? revivedFeature?.project!} projectId={projectId}
open={reviveModalOpen} open={reviveModalOpen}
setOpen={setReviveModalOpen} setOpen={setReviveModalOpen}
refetch={refetch} refetch={refetch}
/> />
</PageContent> </PageContent>
<ConditionallyRender <BatchSelectionActionsBar
condition={Boolean(projectId)} count={Object.keys(selectedRowIds).length}
show={ >
<BatchSelectionActionsBar <ArchiveBatchActions
count={Object.keys(selectedRowIds).length} selectedIds={Object.keys(selectedRowIds)}
> projectId={projectId}
<ArchiveBatchActions onConfirm={() => toggleAllRowsSelected(false)}
selectedIds={Object.keys(selectedRowIds)} />
projectId={projectId!} </BatchSelectionActionsBar>
onConfirm={() => toggleAllRowsSelected(false)}
/>
</BatchSelectionActionsBar>
}
/>
</> </>
); );
}; };

View File

@ -1,32 +0,0 @@
import { useFeaturesArchive } from 'hooks/api/getters/useFeaturesArchive/useFeaturesArchive';
import { ArchiveTable } from './ArchiveTable/ArchiveTable';
import type { SortingRule } from 'react-table';
import { usePageTitle } from 'hooks/usePageTitle';
import { createLocalStorage } from 'utils/createLocalStorage';
const defaultSort: SortingRule<string> = { id: 'createdAt' };
const { value, setValue } = createLocalStorage(
'FeaturesArchiveTable:v1',
defaultSort,
);
export const FeaturesArchiveTable = () => {
usePageTitle('Archive');
const {
archivedFeatures = [],
loading,
refetchArchived,
} = useFeaturesArchive();
return (
<ArchiveTable
title='Archive'
archivedFeatures={archivedFeatures}
loading={loading}
storedParams={value}
setStoredParams={setValue}
refetch={refetchArchived}
/>
);
};

View File

@ -11,7 +11,7 @@ const StyledFeatureId = styled('strong')({
export const FeatureNotFound = () => { export const FeatureNotFound = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const { archivedFeatures } = useFeaturesArchive(); const { archivedFeatures } = useFeaturesArchive(projectId);
const createFeatureTogglePath = getCreateTogglePath(projectId, { const createFeatureTogglePath = getCreateTogglePath(projectId, {
name: featureId, name: featureId,

View File

@ -440,13 +440,6 @@ exports[`returns all baseRoutes 1`] = `
"title": "Login history", "title": "Login history",
"type": "protected", "type": "protected",
}, },
{
"component": [Function],
"menu": {},
"path": "/archive",
"title": "Archived flags",
"type": "protected",
},
{ {
"component": { "component": {
"$$typeof": Symbol(react.lazy), "$$typeof": Symbol(react.lazy),

View File

@ -31,7 +31,6 @@ import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import type { INavigationMenuItem, IRoute } from 'interfaces/route'; import type { INavigationMenuItem, IRoute } from 'interfaces/route';
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable'; import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
import { SegmentTable } from '../segments/SegmentTable/SegmentTable'; import { SegmentTable } from '../segments/SegmentTable/SegmentTable';
import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground'; import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
import { Profile } from 'component/user/Profile/Profile'; import { Profile } from 'component/user/Profile/Profile';
import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView'; import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView';
@ -456,15 +455,6 @@ export const routes: IRoute[] = [
menu: { adminSettings: true }, menu: { adminSettings: true },
}, },
// Archive
{
path: '/archive',
title: 'Archived flags',
component: FeaturesArchiveTable,
type: 'protected',
menu: {},
},
// Admin // Admin
{ {
path: '/admin/*', path: '/admin/*',

View File

@ -9,13 +9,9 @@ const fetcher = (path: string) => {
.then((res) => res.json()); .then((res) => res.json());
}; };
export const useFeaturesArchive = (projectId?: string) => { export const useFeaturesArchive = (projectId: string) => {
const { data, error, mutate, isLoading } = useSWR<ArchivedFeaturesSchema>( const { data, error, mutate, isLoading } = useSWR<ArchivedFeaturesSchema>(
formatApiPath( formatApiPath(`/api/admin/archive/features/${projectId}`),
projectId
? `/api/admin/archive/features/${projectId}`
: 'api/admin/archive/features',
),
fetcher, fetcher,
{ {
refreshInterval: 15 * 1000, // ms refreshInterval: 15 * 1000, // ms

View File

@ -2,10 +2,7 @@ import type { Request, Response } from 'express';
import type { IUnleashConfig } from '../../types/option'; import type { IUnleashConfig } from '../../types/option';
import type { IUnleashServices } from '../../types'; import type { IUnleashServices } from '../../types';
import Controller from '../../routes/controller'; import Controller from '../../routes/controller';
import { import { extractUsername } from '../../util/extract-user';
extractUserIdFromUser,
extractUsername,
} from '../../util/extract-user';
import { DELETE_FEATURE, NONE, UPDATE_FEATURE } from '../../types/permissions'; import { DELETE_FEATURE, NONE, UPDATE_FEATURE } from '../../types/permissions';
import type FeatureToggleService from './feature-toggle-service'; import type FeatureToggleService from './feature-toggle-service';
import type { IAuthRequest } from '../../routes/unleash-types'; import type { IAuthRequest } from '../../routes/unleash-types';
@ -46,28 +43,6 @@ export default class ArchiveController extends Controller {
this.transactionalFeatureToggleService = this.transactionalFeatureToggleService =
transactionalFeatureToggleService; transactionalFeatureToggleService;
this.route({
method: 'get',
path: '/features',
handler: this.getArchivedFeatures,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Archive'],
summary: 'Get archived features',
description:
'Retrieve a list of all [archived feature flags](https://docs.getunleash.io/reference/feature-toggles#archive-a-feature-flag).',
operationId: 'getArchivedFeatures',
responses: {
200: createResponseSchema('archivedFeaturesSchema'),
...getStandardResponses(401, 403),
},
deprecated: true,
}),
],
});
this.route({ this.route({
method: 'get', method: 'get',
path: '/features/:projectId', path: '/features/:projectId',
@ -133,45 +108,13 @@ export default class ArchiveController extends Controller {
}); });
} }
async getArchivedFeatures(
req: IAuthRequest,
res: Response<ArchivedFeaturesSchema>,
): Promise<void> {
const { user } = req;
const features = await this.featureService.getAllArchivedFeatures(
true,
extractUserIdFromUser(user),
);
this.openApiService.respondWithValidation(
200,
res,
archivedFeaturesSchema.$id,
{
version: 2,
features: serializeDates(
features.map((feature) => {
return {
...feature,
stale: feature.stale || false,
archivedAt: feature.archivedAt!,
};
}),
),
},
);
}
async getArchivedFeaturesByProjectId( async getArchivedFeaturesByProjectId(
req: Request<{ projectId: string }, any, any, any>, req: Request<{ projectId: string }, any, any, any>,
res: Response<ArchivedFeaturesSchema>, res: Response<ArchivedFeaturesSchema>,
): Promise<void> { ): Promise<void> {
const { projectId } = req.params; const { projectId } = req.params;
const features = const features =
await this.featureService.getArchivedFeaturesByProjectId( await this.featureService.getArchivedFeaturesByProjectId(projectId);
true,
projectId,
);
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,
res, res,

View File

@ -2162,25 +2162,7 @@ class FeatureToggleService {
); );
} }
async getAllArchivedFeatures(
archived: boolean,
userId: number,
): Promise<FeatureToggle[]> {
const features = await this.featureToggleStore.getArchivedFeatures();
const projectAccess =
await this.privateProjectChecker.getUserAccessibleProjects(userId);
if (projectAccess.mode === 'all') {
return features;
} else {
return features.filter((f) =>
projectAccess.projects.includes(f.project),
);
}
}
async getArchivedFeaturesByProjectId( async getArchivedFeaturesByProjectId(
archived: boolean,
project: string, project: string,
): Promise<FeatureToggle[]> { ): Promise<FeatureToggle[]> {
return this.featureToggleStore.getArchivedFeatures(project); return this.featureToggleStore.getArchivedFeatures(project);

View File

@ -258,7 +258,7 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return rows.map(this.rowToFeature); return rows.map(this.rowToFeature);
} }
async getArchivedFeatures(project?: string): Promise<FeatureToggle[]> { async getArchivedFeatures(project: string): Promise<FeatureToggle[]> {
const builder = new FeatureToggleListBuilder(this.db, [ const builder = new FeatureToggleListBuilder(this.db, [
...commonSelectColumns, ...commonSelectColumns,
'features.archived_at as archived_at', 'features.archived_at as archived_at',
@ -274,18 +274,10 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
'last_seen_at_metrics.environment as last_seen_at_env', 'last_seen_at_metrics.environment as last_seen_at_env',
); );
let rows: any[]; const rows = await builder.internalQuery
.select(builder.getSelectColumns())
if (project) { .where({ project })
rows = await builder.internalQuery .whereNotNull('archived_at');
.select(builder.getSelectColumns())
.where({ project })
.whereNotNull('archived_at');
} else {
rows = await builder.internalQuery
.select(builder.getSelectColumns())
.whereNotNull('archived_at');
}
return this.featureToggleRowConverter.buildArchivedFeatureToggleListFromRows( return this.featureToggleRowConverter.buildArchivedFeatureToggleListFromRows(
rows, rows,

View File

@ -32,16 +32,6 @@ afterAll(async () => {
await db.destroy(); await db.destroy();
}); });
test('Should get empty features via admin', async () => {
await app.request
.get('/api/admin/archive/features')
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.features).toHaveLength(0);
});
});
test('Should be allowed to reuse deleted toggle name', async () => { test('Should be allowed to reuse deleted toggle name', async () => {
await app.request await app.request
.post('/api/admin/projects/default/features') .post('/api/admin/projects/default/features')
@ -61,30 +51,6 @@ test('Should be allowed to reuse deleted toggle name', async () => {
.expect(200); .expect(200);
}); });
test('Should get archived toggles via admin', async () => {
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'archived.test.1',
archived: true,
})
.expect(201);
await app.request
.post('/api/admin/projects/default/features')
.send({
name: 'archived.test.2',
archived: true,
})
.expect(201);
await app.request
.get('/api/admin/archive/features')
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.features).toHaveLength(2);
});
});
test('Should get archived toggles via project', async () => { test('Should get archived toggles via project', async () => {
await db.stores.featureToggleStore.deleteAll(); await db.stores.featureToggleStore.deleteAll();
@ -132,14 +98,6 @@ test('Should get archived toggles via project', async () => {
.expect((res) => { .expect((res) => {
expect(res.body.features).toHaveLength(2); expect(res.body.features).toHaveLength(2);
}); });
await app.request
.get('/api/admin/archive/features')
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
expect(res.body.features).toHaveLength(3);
});
}); });
test('Should be able to revive toggle', async () => { test('Should be able to revive toggle', async () => {

View File

@ -80,26 +80,6 @@ test('response should include last seen at per environment for multiple environm
expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z'); expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z');
}); });
test('response should include last seen at per environment for multiple environments in /api/admin/archive/features', async () => {
const featureName = 'multiple-environment-last-seen-at-archived';
await setupLastSeenAtTest(featureName);
await app.request
.delete(`/api/admin/projects/default/features/${featureName}`)
.expect(202);
const { body } = await app.request.get(`/api/admin/archive/features`);
const featureEnvironments = body.features[0].environments;
const [development, production] = featureEnvironments;
expect(development.name).toBe('development');
expect(development.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z');
expect(production.name).toBe('production');
expect(production.lastSeenAt).toEqual('2023-10-01T12:34:56.000Z');
});
test('response should include last seen at per environment for multiple environments in /api/admin/archive/features/:projectId', async () => { test('response should include last seen at per environment for multiple environments in /api/admin/archive/features/:projectId', async () => {
const featureName = 'multiple-environment-last-seen-at-archived-project'; const featureName = 'multiple-environment-last-seen-at-archived-project';
await setupLastSeenAtTest(featureName); await setupLastSeenAtTest(featureName);

View File

@ -1036,7 +1036,8 @@ test('Patching feature flags to active (turning stale to false) should trigger F
}); });
test('Should archive feature flag', async () => { test('Should archive feature flag', async () => {
const url = '/api/admin/projects/default/features'; const projectId = 'default';
const url = `/api/admin/projects/${projectId}/features`;
const name = 'new.flag.archive'; const name = 'new.flag.archive';
await app.request await app.request
.post(url) .post(url)
@ -1046,7 +1047,7 @@ test('Should archive feature flag', async () => {
await app.request.get(`${url}/${name}`).expect(404); await app.request.get(`${url}/${name}`).expect(404);
const { body } = await app.request const { body } = await app.request
.get(`/api/admin/archive/features`) .get(`/api/admin/archive/features/${projectId}`)
.expect(200); .expect(200);
const flag = body.features.find((f) => f.name === name); const flag = body.features.find((f) => f.name === name);

View File

@ -54,7 +54,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
archived?: boolean, archived?: boolean,
): Promise<FeatureToggle[]>; ): Promise<FeatureToggle[]>;
getArchivedFeatures(project?: string): Promise<FeatureToggle[]>; getArchivedFeatures(project: string): Promise<FeatureToggle[]>;
getPlaygroundFeatures( getPlaygroundFeatures(
featureQuery?: IFeatureToggleQuery, featureQuery?: IFeatureToggleQuery,

View File

@ -68,7 +68,7 @@ afterAll(async () => {
test('returns three archived flags', async () => { test('returns three archived flags', async () => {
expect.assertions(1); expect.assertions(1);
return app.request return app.request
.get('/api/admin/archive/features') .get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
@ -79,7 +79,7 @@ test('returns three archived flags', async () => {
test('returns three archived flags with archivedAt', async () => { test('returns three archived flags with archivedAt', async () => {
expect.assertions(2); expect.assertions(2);
return app.request return app.request
.get('/api/admin/archive/features') .get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {

View File

@ -4,31 +4,6 @@ title: /api/admin/archive
> In order to access the admin API endpoints you need to identify yourself. Unless you're using the `none` authentication method, you'll need to [create an ADMIN token](/how-to/how-to-create-api-tokens) and add an Authorization header using the token. > In order to access the admin API endpoints you need to identify yourself. Unless you're using the `none` authentication method, you'll need to [create an ADMIN token](/how-to/how-to-create-api-tokens) and add an Authorization header using the token.
### Fetch archived flags {#fetch-archived-toggles}
`GET http://unleash.host.com/api/admin/archive/features`
Used to fetch list of archived feature flags
**Example response:**
```json
{
"version": 1,
"features": [
{
"name": "Feature.A",
"description": "lorem ipsum",
"type": "release",
"stale": false,
"variants": [],
"tags": [],
"strategy": "default",
"parameters": {}
}
]
}
```
### Revive feature flag {#revive-feature-toggle} ### Revive feature flag {#revive-feature-toggle}