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

View File

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

View File

@ -13,8 +13,11 @@ export interface ProjectStatusSchema {
activityCountByDate: ProjectActivitySchema;
/** Key resources within the project */
resources: {
/** Handwritten placeholder */
resources: {
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 ProjectStore from '../project/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 = (
db: Db,
@ -16,15 +20,37 @@ export const createProjectStatusService = (
config.getLogger,
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 = () => {
const eventStore = new FakeEventStore();
const projectStore = new FakeProjectStore();
const apiTokenStore = new FakeApiTokenStore();
const segmentStore = new FakeSegmentStore();
const projectStatusService = new ProjectStatusService({
eventStore,
projectStore,
apiTokenStore,
segmentStore,
});
return {

View File

@ -1,28 +1,66 @@
import type { ProjectStatusSchema } from '../../openapi';
import type { IEventStore, IProjectStore, IUnleashStores } from '../../types';
import type {
IApiTokenStore,
IEventStore,
IProjectStore,
ISegmentStore,
IUnleashStores,
} from '../../types';
export class ProjectStatusService {
private eventStore: IEventStore;
private projectStore: IProjectStore;
private apiTokenStore: IApiTokenStore;
private segmentStore: ISegmentStore;
constructor({
eventStore,
projectStore,
}: Pick<IUnleashStores, 'eventStore' | 'projectStore'>) {
apiTokenStore,
segmentStore,
}: Pick<
IUnleashStores,
'eventStore' | 'projectStore' | 'apiTokenStore' | 'segmentStore'
>) {
this.eventStore = eventStore;
this.projectStore = projectStore;
this.apiTokenStore = apiTokenStore;
this.segmentStore = segmentStore;
}
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 {
resources: {
connectedEnvironments:
await this.projectStore.getConnectedEnvironmentCountForProject(
projectId,
),
connectedEnvironments,
members,
apiTokens,
segments,
},
activityCountByDate:
await this.eventStore.getProjectRecentEventActivity(projectId),
activityCountByDate,
};
}
}

View File

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

View File

@ -370,6 +370,15 @@ export default class SegmentStore implements ISegmentStore {
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[] {
return COLUMNS.map((c) => `${T.segments}.${c}`);
}

View File

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

View File

@ -17,7 +17,12 @@ export const projectStatusSchema = {
resources: {
type: 'object',
additionalProperties: false,
required: ['connectedEnvironments'],
required: [
'connectedEnvironments',
'apiTokens',
'members',
'segments',
],
description: 'Key resources within the project',
properties: {
connectedEnvironments: {
@ -26,6 +31,24 @@ export const projectStatusSchema = {
description:
'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 {}
async getProjectSegmentCount(): Promise<number> {
return 0;
}
}