1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

chore (1-3076): add remaining "project resources" to status payload (#8660)

This PR adds member, api token, and segment counts to the project status
payload. It updates the schemas and adds the necessary stores to get
this information. It also adds a new query to the segments store for
getting project segments.

I'll add tests in a follow-up.
This commit is contained in:
Thomas Heartman 2024-11-06 12:46:04 +01:00 committed by GitHub
parent f016a3e34d
commit 8a5771dd50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 155 additions and 41 deletions

View File

@ -1,14 +1,6 @@
import { Typography, styled } from '@mui/material'; import { Typography, styled } from '@mui/material';
import { useProjectApiTokens } from 'hooks/api/getters/useProjectApiTokens/useProjectApiTokens';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { import type { ReactNode, FC, PropsWithChildren } from 'react';
type ReactNode,
useMemo,
type FC,
type PropsWithChildren,
} from 'react';
import UsersIcon from '@mui/icons-material/Group'; import UsersIcon from '@mui/icons-material/Group';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ApiKeyIcon from '@mui/icons-material/Key'; import ApiKeyIcon from '@mui/icons-material/Key';
@ -97,25 +89,32 @@ const ListItem: FC<
</ListItemRow> </ListItemRow>
); );
const useProjectResources = (projectId: string) => {
const { data, loading } = useProjectStatus(projectId);
const { resources } = data ?? {
resources: {
members: 0,
apiTokens: 0,
connectedEnvironments: 0,
segments: 0,
},
};
return {
resources,
loading,
};
};
export const ProjectResources = () => { export const ProjectResources = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId); const { resources, loading } = useProjectResources(projectId);
const { tokens } = useProjectApiTokens(projectId);
const { segments } = useSegments();
const { data: projectStatus, loading: loadingResources } =
useProjectStatus(projectId);
const segmentCount = useMemo( const loadingRef = useLoading(loading, '[data-loading-resources=true]');
() =>
segments?.filter((segment) => segment.project === projectId)
.length ?? 0,
[segments, projectId],
);
const loadingStatusRef = useLoading(true, '[data-loading-resources=true]');
return ( return (
<Wrapper ref={loadingStatusRef}> <Wrapper ref={loadingRef}>
<ProjectResourcesInner> <ProjectResourcesInner>
<Typography variant='h3' sx={{ margin: 0 }}> <Typography variant='h3' sx={{ margin: 0 }}>
Project Resources Project Resources
@ -126,7 +125,7 @@ export const ProjectResources = () => {
linkText='Add members' linkText='Add members'
icon={<UsersIcon />} icon={<UsersIcon />}
> >
{project.members} project member(s) {resources.members} project member(s)
</ListItem> </ListItem>
<ListItem <ListItem
@ -134,7 +133,7 @@ export const ProjectResources = () => {
linkText='Add new key' linkText='Add new key'
icon={<ApiKeyIcon />} icon={<ApiKeyIcon />}
> >
{tokens.length} API key(s) {resources.apiTokens} API key(s)
</ListItem> </ListItem>
<ListItem <ListItem
@ -142,8 +141,8 @@ export const ProjectResources = () => {
linkText='View connections' linkText='View connections'
icon={<ConnectedIcon />} icon={<ConnectedIcon />}
> >
{projectStatus?.resources?.connectedEnvironments}{' '} {resources.connectedEnvironments} connected
connected environment(s) environment(s)
</ListItem> </ListItem>
<ListItem <ListItem
@ -151,7 +150,7 @@ export const ProjectResources = () => {
linkText='Add segments' linkText='Add segments'
icon={<SegmentsIcon />} icon={<SegmentsIcon />}
> >
{segmentCount} project segment(s) {resources.segments} project segment(s)
</ListItem> </ListItem>
</ResourceList> </ResourceList>
</ProjectResourcesInner> </ProjectResourcesInner>

View File

@ -6,7 +6,12 @@ const path = (projectId: string) => `api/admin/projects/${projectId}/status`;
const placeholderData: ProjectStatusSchema = { const placeholderData: ProjectStatusSchema = {
activityCountByDate: [], activityCountByDate: [],
resources: { connectedEnvironments: 0 }, resources: {
connectedEnvironments: 0,
members: 0,
apiTokens: 0,
segments: 0,
},
}; };
export const useProjectStatus = (projectId: string) => { export const useProjectStatus = (projectId: string) => {

View File

@ -13,8 +13,11 @@ export interface ProjectStatusSchema {
activityCountByDate: ProjectActivitySchema; activityCountByDate: ProjectActivitySchema;
/** Key resources within the project */ /** Key resources within the project */
resources: {
/** Handwritten placeholder */ /** Handwritten placeholder */
resources: {
connectedEnvironments: number; connectedEnvironments: number;
apiTokens: number;
members: number;
segments: number;
}; };
} }

View File

@ -4,6 +4,10 @@ import EventStore from '../events/event-store';
import FakeEventStore from '../../../test/fixtures/fake-event-store'; import FakeEventStore from '../../../test/fixtures/fake-event-store';
import ProjectStore from '../project/project-store'; import ProjectStore from '../project/project-store';
import FakeProjectStore from '../../../test/fixtures/fake-project-store'; import FakeProjectStore from '../../../test/fixtures/fake-project-store';
import FakeApiTokenStore from '../../../test/fixtures/fake-api-token-store';
import { ApiTokenStore } from '../../db/api-token-store';
import SegmentStore from '../segment/segment-store';
import FakeSegmentStore from '../../../test/fixtures/fake-segment-store';
export const createProjectStatusService = ( export const createProjectStatusService = (
db: Db, db: Db,
@ -16,15 +20,37 @@ export const createProjectStatusService = (
config.getLogger, config.getLogger,
config.flagResolver, config.flagResolver,
); );
return new ProjectStatusService({ eventStore, projectStore }); const apiTokenStore = new ApiTokenStore(
db,
config.eventBus,
config.getLogger,
config.flagResolver,
);
const segmentStore = new SegmentStore(
db,
config.eventBus,
config.getLogger,
config.flagResolver,
);
return new ProjectStatusService({
eventStore,
projectStore,
apiTokenStore,
segmentStore,
});
}; };
export const createFakeProjectStatusService = () => { export const createFakeProjectStatusService = () => {
const eventStore = new FakeEventStore(); const eventStore = new FakeEventStore();
const projectStore = new FakeProjectStore(); const projectStore = new FakeProjectStore();
const apiTokenStore = new FakeApiTokenStore();
const segmentStore = new FakeSegmentStore();
const projectStatusService = new ProjectStatusService({ const projectStatusService = new ProjectStatusService({
eventStore, eventStore,
projectStore, projectStore,
apiTokenStore,
segmentStore,
}); });
return { return {

View File

@ -1,28 +1,66 @@
import type { ProjectStatusSchema } from '../../openapi'; import type { ProjectStatusSchema } from '../../openapi';
import type { IEventStore, IProjectStore, IUnleashStores } from '../../types'; import type {
IApiTokenStore,
IEventStore,
IProjectStore,
ISegmentStore,
IUnleashStores,
} from '../../types';
export class ProjectStatusService { export class ProjectStatusService {
private eventStore: IEventStore; private eventStore: IEventStore;
private projectStore: IProjectStore; private projectStore: IProjectStore;
private apiTokenStore: IApiTokenStore;
private segmentStore: ISegmentStore;
constructor({ constructor({
eventStore, eventStore,
projectStore, projectStore,
}: Pick<IUnleashStores, 'eventStore' | 'projectStore'>) { apiTokenStore,
segmentStore,
}: Pick<
IUnleashStores,
'eventStore' | 'projectStore' | 'apiTokenStore' | 'segmentStore'
>) {
this.eventStore = eventStore; this.eventStore = eventStore;
this.projectStore = projectStore; this.projectStore = projectStore;
this.apiTokenStore = apiTokenStore;
this.segmentStore = segmentStore;
} }
async getProjectStatus(projectId: string): Promise<ProjectStatusSchema> { async getProjectStatus(projectId: string): Promise<ProjectStatusSchema> {
const [
connectedEnvironments,
members,
apiTokens,
segments,
activityCountByDate,
] = await Promise.all([
this.projectStore.getConnectedEnvironmentCountForProject(projectId),
this.projectStore.getMembersCountByProject(projectId),
this.apiTokenStore
.getAll()
.then(
(tokens) =>
tokens.filter(
(token) =>
token.project === projectId ||
token.projects.includes(projectId),
).length,
),
this.segmentStore.getProjectSegmentCount(projectId),
this.eventStore.getProjectRecentEventActivity(projectId),
]);
return { return {
resources: { resources: {
connectedEnvironments: connectedEnvironments,
await this.projectStore.getConnectedEnvironmentCountForProject( members,
projectId, apiTokens,
), segments,
}, },
activityCountByDate: activityCountByDate,
await this.eventStore.getProjectRecentEventActivity(projectId),
}; };
} }
} }

View File

@ -25,4 +25,6 @@ export interface ISegmentStore extends Store<ISegment, number> {
existsByName(name: string): Promise<boolean>; existsByName(name: string): Promise<boolean>;
count(): Promise<number>; count(): Promise<number>;
getProjectSegmentCount(projectId: string): Promise<number>;
} }

View File

@ -370,6 +370,15 @@ export default class SegmentStore implements ISegmentStore {
return Boolean(rows[0]); return Boolean(rows[0]);
} }
async getProjectSegmentCount(projectId: string): Promise<number> {
const result = await this.db.raw(
`SELECT COUNT(*) FROM ${T.segments} WHERE segment_project_id = ?`,
[projectId],
);
return Number(result.rows[0].count);
}
prefixColumns(): string[] { prefixColumns(): string[] {
return COLUMNS.map((c) => `${T.segments}.${c}`); return COLUMNS.map((c) => `${T.segments}.${c}`);
} }

View File

@ -7,7 +7,12 @@ test('projectStatusSchema', () => {
{ date: '2022-12-14', count: 2 }, { date: '2022-12-14', count: 2 },
{ date: '2022-12-15', count: 5 }, { date: '2022-12-15', count: 5 },
], ],
resources: { connectedEnvironments: 2 }, resources: {
connectedEnvironments: 2,
apiTokens: 2,
members: 1,
segments: 0,
},
}; };
expect( expect(

View File

@ -17,7 +17,12 @@ export const projectStatusSchema = {
resources: { resources: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['connectedEnvironments'], required: [
'connectedEnvironments',
'apiTokens',
'members',
'segments',
],
description: 'Key resources within the project', description: 'Key resources within the project',
properties: { properties: {
connectedEnvironments: { connectedEnvironments: {
@ -26,6 +31,24 @@ export const projectStatusSchema = {
description: description:
'The number of environments that have received SDK traffic in this project.', 'The number of environments that have received SDK traffic in this project.',
}, },
apiTokens: {
type: 'number',
minimum: 0,
description:
'The number of API tokens created specifically for this project.',
},
members: {
type: 'number',
minimum: 0,
description:
'The number of users who have been granted roles in this project. Does not include users who have access via groups.',
},
segments: {
type: 'number',
minimum: 0,
description:
'The number of segments that are scoped to this project.',
},
}, },
}, },
}, },

View File

@ -62,4 +62,8 @@ export default class FakeSegmentStore implements ISegmentStore {
} }
destroy(): void {} destroy(): void {}
async getProjectSegmentCount(): Promise<number> {
return 0;
}
} }