introduce support for teams and organization (#1261)

Signed-off-by: Alisson Fabiano <afabiano@eshopworld.com>
This commit is contained in:
esw-afabiano 2024-11-19 06:46:12 -03:00 committed by GitHub
parent d3d078b35b
commit 7f17c9f54e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2664 additions and 981 deletions

View File

@ -0,0 +1,7 @@
---
'@backstage-community/plugin-copilot-backend': minor
'@backstage-community/plugin-copilot-common': minor
'@backstage-community/plugin-copilot': minor
---
Introduced support for organizations and team metrics visualization in the Copilot plugin.

View File

@ -51,7 +51,7 @@ import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import { CatalogGraphPage } from '@backstage/plugin-catalog-graph';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
import { CopilotPage } from '@backstage-community/plugin-copilot';
import { CopilotIndexPage } from '@backstage-community/plugin-copilot';
const app = createApp({
apis,
@ -111,10 +111,9 @@ const routes = (
</Route>
<Route path="/settings" element={<UserSettingsPage />} />
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
<Route path="/copilot" element={<CopilotPage />} />
<Route path="/copilot" element={<CopilotIndexPage />} />
</FlatRoutes>
);
export default app.createRoot(
<>
<AlertDisplay />

View File

@ -19,7 +19,6 @@ import HomeIcon from '@material-ui/icons/Home';
import ExtensionIcon from '@material-ui/icons/Extension';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import GithubIcon from '@material-ui/icons/GitHub';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import {
@ -41,6 +40,7 @@ import {
} from '@backstage/core-components';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
import { CopilotSidebar } from '@backstage-community/plugin-copilot';
const useSidebarLogoStyles = makeStyles({
root: {
@ -87,7 +87,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={GithubIcon} to="copilot" text="Copilot" />
<CopilotSidebar />
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />

View File

@ -15,7 +15,7 @@ To configure the plugin using the new backend system:
const backend = createBackend();
backend.add(import('@backstage-community/plugin-copilot'));
backend.add(import('@backstage-community/plugin-copilot-backend'));
backend.start();
```
@ -24,17 +24,17 @@ To configure the plugin using the new backend system:
To install the plugin using the old method:
1. Add the `@backstage-community/plugin-copilot` package to your backend:
1. Add the `@backstage-community/plugin-copilot-backend` package to your backend:
```sh
yarn --cwd packages/backend add @backstage-community/plugin-copilot
yarn --cwd packages/backend add @backstage-community/plugin-copilot-backend
```
2. In your `packages/backend/src/plugins/copilot.ts` file, add the following code:
```typescript
import { TaskScheduleDefinition } from '@backstage/backend-tasks';
import { createRouterFromConfig } from '@backstage-community/plugin-copilot';
import { createRouterFromConfig } from '@backstage-community/plugin-copilot-backend';
export default async function createPlugin(): Promise<void> {
const schedule: TaskScheduleDefinition = {
@ -53,9 +53,7 @@ To install the plugin using the old method:
import { createRouterFromConfig } from './plugins/copilot';
async function main() {
// Backend setup
const env = createEnv('copilot');
// Plugin registration
apiRouter.use('/copilot', await createRouterFromConfig(env));
}
```
@ -68,14 +66,36 @@ To configure the GitHub Copilot plugin, you need to set the following environmen
- **`copilot.host`**: The host URL for your GitHub Copilot instance (e.g., `github.com` or `github.enterprise.com`).
- **`copilot.enterprise`**: The name of your GitHub Enterprise instance (e.g., `my-enterprise`).
- **`copilot.organization`**: The name of your GitHub Organization (e.g., `my-organization`).
These variables are used to configure the plugin and ensure it communicates with the correct GitHub instance.
### GitHub Credentials
**Important:** The GitHub token, which is necessary for authentication, should be managed within your Backstage integrations configuration. The token must be added to your GitHub integration settings, and the plugin will retrieve it through the `GithubCredentialsProvider`.
**Important:** The GitHub token, necessary for authentication, should be managed within your Backstage integrations configuration. Ensure that your GitHub integration in the Backstage configuration includes the necessary token for the `GithubCredentialsProvider` to function correctly.
Ensure that your GitHub integration in the Backstage configuration includes the necessary token for the `GithubCredentialsProvider` to work correctly.
### GitHub Token Scopes
To ensure the GitHub Copilot plugin operates correctly within your organization or enterprise, your GitHub access token must include specific scopes. These scopes grant the plugin the necessary permissions to interact with your GitHub organization and manage Copilot usage.
#### Required Scopes
1. **List Teams Endpoint**
- **Scope Required:** `read:org`
- **Purpose:** Allows the plugin to list all teams within your GitHub organization.
2. **Copilot Usage**
- **Scopes Required - enterprise:** `manage_billing:copilot`, `read:enterprise`
- **Scopes Required - organization:** `manage_billing:copilot`, `read:org`, or `read:enterprise`
- **Purpose:** Enables the plugin to manage and monitor GitHub Copilot usage within your organization or/and enterprise.
#### How to Configure Token Scopes
1. **Generate a Personal Access Token (PAT):**
- Navigate to [GitHub Personal Access Tokens](https://github.com/settings/tokens).
- Click on **Generate new token**.
- Select the scopes according to your needs
### YAML Configuration Example
@ -90,20 +110,20 @@ copilot:
seconds: 15
host: YOUR_GITHUB_HOST_HERE
enterprise: YOUR_ENTERPRISE_NAME_HERE
organization: YOUR_ORGANIZATION_NAME_HERE
integrations:
github:
- host: YOUR_GITHUB_HOST_HERE
token: YOUR_GENERATED_TOKEN
```
### Generating GitHub Copilot Token
To generate an access token for using GitHub Copilot:
- Visit [Generate GitHub Access Token](https://github.com/settings/tokens).
- Follow the instructions to create a new token with the `read:enterprise` scope.
### API Documentation
For more details on using the GitHub Copilot API:
For more details on using the GitHub Copilot and Teams APIs, refer to the following documentation:
- Refer to the [API documentation](https://docs.github.com/en/rest/copilot/copilot-usage?apiVersion=2022-11-28) for comprehensive information on available functionalities.
- [GitHub Teams API - List Teams](https://docs.github.com/en/rest/teams/teams?apiVersion=2022-11-28#list-teams)
- [GitHub Copilot API - Usage](https://docs.github.com/en/rest/copilot/copilot-usage?apiVersion=2022-11-28)
## Run

View File

@ -1,5 +1,5 @@
/*
* Copyright 2023 The Backstage Authors
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -30,6 +30,10 @@ export interface Config {
* The name of the GitHub enterprise.
*/
enterprise?: string;
/**
* The name of the GitHub organization.
*/
organization?: string;
/**
* The host for GitHub Copilot integration.
*/

View File

@ -0,0 +1,48 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.table('metrics', table => {
table
.string('type', 50)
.defaultTo('enterprise')
.notNullable()
.comment('Type of the metrics data: enterprise, organization');
table.string('team_name', 255).nullable().comment('Name of the team');
table.dropPrimary();
table.unique(['day', 'type', 'team_name'], 'uk_day_type_team_name');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.table('metrics', table => {
table.dropUnique(['day', 'type', 'team_name']);
table.dropColumn('type');
table.dropColumn('team_name');
table.primary('day');
table.index('day', 'idx_metrics_day');
});
};

View File

@ -54,7 +54,8 @@
"luxon": "^3.5.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1",
"yn": "^4.0.0"
"yn": "^4.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@backstage/backend-test-utils": "^1.0.2",

View File

@ -16,12 +16,17 @@
import { ResponseError } from '@backstage/errors';
import { Config } from '@backstage/config';
import { Metric } from '@backstage-community/plugin-copilot-common';
import { Metric, TeamInfo } from '@backstage-community/plugin-copilot-common';
import fetch from 'node-fetch';
import { getGithubInfo, GithubInfo } from '../utils/GithubUtils';
interface GithubApi {
getCopilotUsageDataForEnterprise: () => Promise<Metric[]>;
fetchEnterpriseCopilotUsage: () => Promise<Metric[]>;
fetchEnterpriseTeamCopilotUsage: (teamId: string) => Promise<Metric[]>;
fetchEnterpriseTeams: () => Promise<TeamInfo[]>;
fetchOrganizationCopilotUsage: () => Promise<Metric[]>;
fetchOrganizationTeamCopilotUsage: (teamId: string) => Promise<Metric[]>;
fetchOrganizationTeams: () => Promise<TeamInfo[]>;
}
export class GithubClient implements GithubApi {
@ -32,11 +37,36 @@ export class GithubClient implements GithubApi {
return new GithubClient(info);
}
async getCopilotUsageDataForEnterprise(): Promise<Metric[]> {
async fetchEnterpriseCopilotUsage(): Promise<Metric[]> {
const path = `/enterprises/${this.props.enterprise}/copilot/usage`;
return this.get(path);
}
async fetchEnterpriseTeamCopilotUsage(teamId: string): Promise<Metric[]> {
const path = `/enterprises/${this.props.enterprise}/team/${teamId}/copilot/usage`;
return this.get(path);
}
async fetchEnterpriseTeams(): Promise<TeamInfo[]> {
const path = `/enterprises/${this.props.enterprise}/teams`;
return this.get(path);
}
async fetchOrganizationCopilotUsage(): Promise<Metric[]> {
const path = `/orgs/${this.props.organization}/copilot/usage`;
return this.get(path);
}
async fetchOrganizationTeamCopilotUsage(teamId: string): Promise<Metric[]> {
const path = `/orgs/${this.props.organization}/team/${teamId}/copilot/usage`;
return this.get(path);
}
async fetchOrganizationTeams(): Promise<TeamInfo[]> {
const path = `/orgs/${this.props.organization}/teams`;
return this.get(path);
}
private async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.props.apiBaseUrl}${path}`, {
headers: {

View File

@ -20,6 +20,7 @@ import {
} from '@backstage/backend-plugin-api';
import {
Metric,
MetricsType,
PeriodRange,
} from '@backstage-community/plugin-copilot-common';
import { Knex } from 'knex';
@ -53,46 +54,72 @@ export class DatabaseHandler {
private constructor(private readonly db: Knex) {}
async getMetricsByPeriod(
startDate: string,
endDate: string,
): Promise<MetricDbRow[]> {
const records = await this.db<MetricDbRow>('metrics').whereBetween('day', [
startDate,
endDate,
]);
return records ?? [];
}
async getPeriodRange(type: MetricsType): Promise<PeriodRange | undefined> {
const query = this.db<MetricDbRow>('metrics').where('type', type);
async getPeriodRange(): Promise<PeriodRange | undefined> {
const minDate = await this.db<MetricDbRow>('metrics')
.orderBy('day', 'asc')
.first('day');
const maxDate = await this.db<MetricDbRow>('metrics')
.orderBy('day', 'desc')
.first('day');
const minDate = await query.orderBy('day', 'asc').first('day');
const maxDate = await query.orderBy('day', 'desc').first('day');
if (!minDate?.day || !maxDate?.day) return undefined;
return { minDate: minDate.day, maxDate: maxDate.day };
}
async getTeams(
type: MetricsType,
startDate: string,
endDate: string,
): Promise<Array<string | undefined>> {
const result = await this.db<MetricDbRow>('metrics')
.where('type', type)
.whereBetween('day', [startDate, endDate])
.whereNotNull('team_name')
.distinct('team_name')
.orderBy('team_name', 'asc')
.select('team_name');
return result.map(x => x.team_name);
}
async batchInsert(metrics: MetricDbRow[]): Promise<void> {
await this.db<MetricDbRow[]>('metrics')
.insert(metrics)
.onConflict('day')
.onConflict(['day', 'type', 'team_name'])
.ignore();
}
async getMostRecentDayFromMetrics(): Promise<string | undefined> {
async getMostRecentDayFromMetrics(
type: MetricsType,
teamName?: string,
): Promise<string | undefined> {
try {
const mostRecent = await this.db<MetricDbRow>('metrics')
const query = await this.db<MetricDbRow>('metrics')
.where('type', type)
.where('team_name', teamName ?? null)
.orderBy('day', 'desc')
.first('day');
return mostRecent ? mostRecent.day : undefined;
return query ? query.day : undefined;
} catch (e) {
return undefined;
}
}
async getMetrics(
startDate: string,
endDate: string,
type: MetricsType,
teamName?: string,
): Promise<MetricDbRow[]> {
console.log(startDate, endDate, type, teamName);
if (teamName) {
return await this.db<MetricDbRow>('metrics')
.where('type', type)
.where('team_name', teamName)
.whereBetween('day', [startDate, endDate]);
}
return this.db<MetricDbRow>('metrics')
.where('type', type)
.whereNull('team_name')
.whereBetween('day', [startDate, endDate]);
}
}

View File

@ -24,11 +24,20 @@ import {
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
import { Metric } from '@backstage-community/plugin-copilot-common';
import { DatabaseHandler } from '../db/DatabaseHandler';
import Scheduler from '../task/Scheduler';
import TaskManagement from '../task/TaskManagement';
import { GithubClient } from '../client/GithubClient';
import { DateTime } from 'luxon';
import { validateQuery } from './validation/validateQuery';
import {
MetricsQuery,
metricsQuerySchema,
PeriodRangeQuery,
periodRangeQuerySchema,
TeamQuery,
teamQuerySchema,
} from './validation/schema';
/**
* Options for configuring the Copilot plugin.
@ -115,7 +124,8 @@ async function createRouter(
await scheduler.scheduleTask({
id: 'copilot-metrics',
...(schedule ?? defaultSchedule),
fn: async () => await Scheduler.create({ db, logger, api, config }).run(),
fn: async () =>
await TaskManagement.create({ db, logger, api, config }).runAsync(),
});
const router = Router();
@ -126,38 +136,48 @@ async function createRouter(
response.json({ status: 'ok' });
});
router.get('/metrics', async (request, response) => {
const { startDate, endDate } = request.query;
router.get(
'/metrics',
validateQuery(metricsQuerySchema),
async (req, res) => {
const { startDate, endDate, type, team } = req.query as MetricsQuery;
if (typeof startDate !== 'string' || typeof endDate !== 'string') {
return response.status(400).json('Invalid query parameters');
}
const result = await db.getMetrics(startDate, endDate, type, team);
const parsedStartDate = DateTime.fromISO(startDate);
const parsedEndDate = DateTime.fromISO(endDate);
const metrics: Metric[] = result.map(metric => ({
...metric,
breakdown: JSON.parse(metric.breakdown),
}));
if (!parsedStartDate.isValid || !parsedEndDate.isValid) {
return response.status(400).json('Invalid date format');
}
return res.json(metrics);
},
);
const result = await db.getMetricsByPeriod(startDate, endDate);
router.get(
'/metrics/period-range',
validateQuery(periodRangeQuerySchema),
async (req, res) => {
const { type } = req.query as PeriodRangeQuery;
const result = await db.getPeriodRange(type);
const metrics: Metric[] = result.map(metric => ({
...metric,
breakdown: JSON.parse(metric.breakdown),
}));
if (!result) {
throw new NotFoundError();
}
return response.json(metrics);
});
return res.json(result);
},
);
router.get('/metrics/period-range', async (_, response) => {
const result = await db.getPeriodRange();
router.get('/teams', validateQuery(teamQuerySchema), async (req, res) => {
const { type, startDate, endDate } = req.query as TeamQuery;
const result = await db.getTeams(type, startDate, endDate);
if (!result) {
return response.status(400).json('No available data');
throw new NotFoundError();
}
return response.json(result);
return res.json(result);
});
router.use(MiddlewareFactory.create({ config, logger }).error);

View File

@ -0,0 +1,57 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod';
import { MetricsType } from '@backstage-community/plugin-copilot-common';
const metricsTypeSchema = z.enum(['enterprise', 'organization']);
const isoDateSchema = z.string().refine(date => !isNaN(Date.parse(date)), {
message: 'Invalid date format',
});
export type MetricsQuery = {
startDate: string;
endDate: string;
type: MetricsType;
team?: string;
};
export type PeriodRangeQuery = {
type: MetricsType;
};
export type TeamQuery = {
type: MetricsType;
startDate: string;
endDate: string;
};
export const metricsQuerySchema = z.object({
startDate: isoDateSchema,
endDate: isoDateSchema,
type: metricsTypeSchema,
team: z.string().optional(),
});
export const periodRangeQuerySchema = z.object({
type: metricsTypeSchema,
});
export const teamQuerySchema = z.object({
startDate: isoDateSchema,
endDate: isoDateSchema,
type: metricsTypeSchema,
});

View File

@ -0,0 +1,32 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import { NextFunction, Request, Response } from 'express';
import { ZodSchema } from 'zod';
export function validateQuery(schema: ZodSchema) {
return (req: Request, _res: Response, next: NextFunction) => {
const { error, data } = schema.safeParse(req.query);
if (error) {
throw new InputError(error.errors[0].message, error);
}
req.query = data;
return next();
};
}

View File

@ -0,0 +1,74 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MetricsType } from '@backstage-community/plugin-copilot-common';
import { MetricDbRow } from '../db/DatabaseHandler';
import { batchInsertInChunks } from '../utils/batchInsert';
import {
filterNewMetrics,
prepareMetricsForInsert,
} from '../utils/metricHelpers';
import { TaskOptions } from './TaskManagement';
export async function discoverEnterpriseMetrics({
api,
logger,
db,
config,
}: TaskOptions): Promise<void> {
if (!config.getOptionalString('copilot.enterprise')) {
logger.info(
'[discoverEnterpriseMetrics] Skipping: Enterprise configuration not found.',
);
return;
}
const type: MetricsType = 'enterprise';
try {
const metrics = await api.fetchEnterpriseCopilotUsage();
logger.info(
`[discoverEnterpriseMetrics] Fetched ${metrics.length} metrics`,
);
const lastDay = await db.getMostRecentDayFromMetrics(type);
logger.info(`[discoverEnterpriseMetrics] Found last day: ${lastDay}`);
const newMetrics = filterNewMetrics(metrics, lastDay);
logger.info(
`[discoverEnterpriseMetrics] Found ${newMetrics.length} new metrics to insert`,
);
if (newMetrics.length > 0) {
await batchInsertInChunks<MetricDbRow>(
prepareMetricsForInsert(newMetrics, type),
30,
async (chunk: MetricDbRow[]) => {
await db.batchInsert(chunk);
},
);
logger.info(
'[discoverEnterpriseMetrics] Inserted new metrics into the database',
);
} else {
logger.info('[discoverEnterpriseMetrics] No new metrics found to insert');
}
} catch (error) {
logger.error(
`[discoverEnterpriseMetrics] An error occurred while processing Github Copilot metrics: ${error}`,
);
throw error;
}
}

View File

@ -0,0 +1,95 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MetricsType } from '@backstage-community/plugin-copilot-common';
import { MetricDbRow } from '../db/DatabaseHandler';
import { batchInsertInChunks } from '../utils/batchInsert';
import {
filterNewMetrics,
prepareMetricsForInsert,
} from '../utils/metricHelpers';
import { TaskOptions } from './TaskManagement';
export async function discoverEnterpriseTeamMetrics({
api,
logger,
db,
config,
}: TaskOptions): Promise<void> {
if (!config.getOptionalString('copilot.enterprise')) {
logger.info(
'[discoverEnterpriseTeamMetrics] Skipping: Enterprise configuration not found.',
);
return;
}
const type: MetricsType = 'enterprise';
try {
const teams = await api.fetchEnterpriseTeams();
logger.info(
`[discoverEnterpriseTeamMetrics] Fetched ${teams.length} teams`,
);
for (const team of teams) {
try {
logger.info(
`[discoverEnterpriseTeamMetrics] Fetching metrics for team: ${team.slug}`,
);
const metrics = await api.fetchEnterpriseTeamCopilotUsage(team.slug);
logger.info(
`[discoverEnterpriseTeamMetrics] Fetched ${metrics.length} metrics for team: ${team.slug}`,
);
const lastDay = await db.getMostRecentDayFromMetrics(type, team.slug);
logger.info(
`[discoverEnterpriseTeamMetrics] Found last processed day for team ${team.slug}: ${lastDay}`,
);
const newMetrics = filterNewMetrics(metrics, lastDay);
logger.info(
`[discoverEnterpriseTeamMetrics] Found ${newMetrics.length} new metrics for team ${team.slug} to insert`,
);
if (newMetrics.length > 0) {
await batchInsertInChunks<MetricDbRow>(
prepareMetricsForInsert(newMetrics, type, team.slug),
30,
async (chunk: MetricDbRow[]) => {
await db.batchInsert(chunk);
},
);
logger.info(
`[discoverEnterpriseTeamMetrics] Successfully inserted new metrics for team ${team.slug} into the database`,
);
} else {
logger.info(
`[discoverEnterpriseTeamMetrics] No new metrics found for team ${team.slug} to insert`,
);
}
} catch (error) {
logger.error(
`[discoverEnterpriseTeamMetrics] Error processing metrics for team ${team.slug}: ${error}`,
);
}
}
} catch (error) {
logger.error(
`[discoverEnterpriseTeamMetrics] Error fetching teams: ${error}`,
);
throw error;
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MetricsType } from '@backstage-community/plugin-copilot-common';
import { MetricDbRow } from '../db/DatabaseHandler';
import { batchInsertInChunks } from '../utils/batchInsert';
import {
filterNewMetrics,
prepareMetricsForInsert,
} from '../utils/metricHelpers';
import { TaskOptions } from './TaskManagement';
export async function discoverOrganizationMetrics({
api,
logger,
db,
config,
}: TaskOptions): Promise<void> {
if (!config.getOptionalString('copilot.organization')) {
logger.info(
'[discoverOrganizationMetrics] Skipping: Organization configuration not found.',
);
return;
}
const type: MetricsType = 'organization';
try {
const metrics = await api.fetchOrganizationCopilotUsage();
logger.info(
`[discoverOrganizationMetrics] Fetched ${metrics.length} metrics`,
);
const lastDay = await db.getMostRecentDayFromMetrics(type);
logger.info(`[discoverOrganizationMetrics] Found last day: ${lastDay}`);
const newMetrics = filterNewMetrics(metrics, lastDay);
logger.info(
`[discoverOrganizationMetrics] Found ${newMetrics.length} new metrics to insert`,
);
if (newMetrics.length > 0) {
await batchInsertInChunks<MetricDbRow>(
prepareMetricsForInsert(newMetrics, type),
30,
async (chunk: MetricDbRow[]) => {
await db.batchInsert(chunk);
},
);
logger.info(
'[discoverOrganizationMetrics] Inserted new metrics into the database',
);
} else {
logger.info(
'[discoverOrganizationMetrics] No new metrics found to insert',
);
}
} catch (error) {
logger.error(
`[discoverOrganizationMetrics] An error occurred while processing Github Copilot metrics: ${error}`,
);
throw error;
}
}

View File

@ -0,0 +1,96 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MetricsType } from '@backstage-community/plugin-copilot-common';
import { MetricDbRow } from '../db/DatabaseHandler';
import { batchInsertInChunks } from '../utils/batchInsert';
import {
filterNewMetrics,
prepareMetricsForInsert,
} from '../utils/metricHelpers';
import { TaskOptions } from './TaskManagement';
export async function discoverOrganizationTeamMetrics({
api,
logger,
db,
config,
}: TaskOptions): Promise<void> {
if (!config.getOptionalString('copilot.organization')) {
logger.info(
'[discoverOrganizationTeamMetrics] Skipping: Organization configuration not found.',
);
return;
}
const type: MetricsType = 'organization';
try {
const teams = await api.fetchOrganizationTeams();
logger.info(
`[discoverOrganizationTeamMetrics] Fetched ${teams.length} teams`,
);
for (const team of teams) {
try {
logger.info(
`[discoverOrganizationTeamMetrics] Fetching metrics for team: ${team.slug}`,
);
const metrics = await api.fetchOrganizationTeamCopilotUsage(team.slug);
logger.info(
`[discoverOrganizationTeamMetrics] Fetched ${metrics.length} metrics for team: ${team.slug}`,
);
const lastDay = await db.getMostRecentDayFromMetrics(type, team.slug);
logger.info(
`[discoverOrganizationTeamMetrics] Found last processed day for team ${team.slug}: ${lastDay}`,
);
const newMetrics = filterNewMetrics(metrics, lastDay);
logger.info(
`[discoverOrganizationTeamMetrics] Found ${newMetrics.length} new metrics for team ${team.slug} to insert`,
);
if (newMetrics.length > 0) {
await batchInsertInChunks<MetricDbRow>(
prepareMetricsForInsert(newMetrics, type, team.slug),
30,
async (chunk: MetricDbRow[]) => {
await db.batchInsert(chunk);
},
);
logger.info(
`[discoverOrganizationTeamMetrics] Successfully inserted new metrics for team ${team.slug} into the database`,
);
} else {
logger.info(
`[discoverOrganizationTeamMetrics] No new metrics found for team ${team.slug} to insert`,
);
}
} catch (error) {
logger.error(
`[discoverOrganizationTeamMetrics] Error processing metrics for team ${team.slug}: ${error}`,
);
}
}
} catch (error) {
logger.error(
`[discoverOrganizationTeamMetrics] Error fetching teams: ${error}`,
);
throw error;
}
}

View File

@ -1,95 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService } from '@backstage/backend-plugin-api';
import { GithubClient } from '../client/GithubClient';
import { DatabaseHandler, MetricDbRow } from '../db/DatabaseHandler';
import { Config } from '@backstage/config';
import { DateTime } from 'luxon';
type Options = {
api: GithubClient;
config: Config;
logger: LoggerService;
db: DatabaseHandler;
};
export default class Scheduler {
constructor(private readonly options: Options) {}
static create(options: Options) {
return new Scheduler(options);
}
async run() {
try {
this.options.logger.info('Starting Github Copilot Processor');
const copilotMetrics =
await this.options.api.getCopilotUsageDataForEnterprise();
this.options.logger.info(`Fetched ${copilotMetrics.length} metrics`);
const lastDay = await this.options.db.getMostRecentDayFromMetrics();
this.options.logger.info(`Found last day: ${lastDay}`);
const diff = copilotMetrics
.sort(
(a, b) =>
DateTime.fromISO(a.day).toMillis() -
DateTime.fromISO(b.day).toMillis(),
)
.filter(metric => {
const metricDate = DateTime.fromISO(metric.day);
const lastDayDate = lastDay ? DateTime.fromISO(lastDay) : null;
return !lastDayDate || metricDate > lastDayDate;
})
.map(({ breakdown, ...rest }) => ({
...rest,
breakdown: JSON.stringify(breakdown),
}));
this.options.logger.info(`Found ${diff.length} new metrics to insert`);
if (diff.length > 0) {
await batchInsertInChunks<MetricDbRow>(
diff,
30,
async (chunk: MetricDbRow[]) => {
await this.options.db.batchInsert(chunk);
},
);
this.options.logger.info('Inserted new metrics into the database');
} else {
this.options.logger.info('No new metrics found to insert');
}
} catch (error) {
this.options.logger.error(
`An error occurred while processing Github Copilot metrics: ${error}`,
);
}
}
}
async function batchInsertInChunks<T>(
data: T[],
chunkSize: number,
batchInsertFunc: (chunk: T[]) => Promise<void>,
): Promise<void> {
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
await batchInsertFunc(chunk);
}
}

View File

@ -0,0 +1,69 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { LoggerService } from '@backstage/backend-plugin-api';
import { GithubClient } from '../client/GithubClient';
import { DatabaseHandler } from '../db/DatabaseHandler';
import { discoverOrganizationMetrics } from './OrganizationTask';
import { discoverOrganizationTeamMetrics } from './OrganizationTeamTask';
import { discoverEnterpriseMetrics } from './EnterpriseTask';
import { discoverEnterpriseTeamMetrics } from './EnterpriseTeamTask';
export type TaskOptions = {
api: GithubClient;
config: Config;
logger: LoggerService;
db: DatabaseHandler;
};
export default class TaskManagement {
private readonly tasks: Array<() => Promise<void>>;
constructor(private readonly options: TaskOptions) {
this.tasks = [
() => discoverOrganizationMetrics(this.options),
() => discoverOrganizationTeamMetrics(this.options),
() => discoverEnterpriseMetrics(this.options),
() => discoverEnterpriseTeamMetrics(this.options),
];
}
static create(options: TaskOptions) {
return new TaskManagement(options);
}
async runAsync() {
this.options.logger.info(
`[TaskManagement] Starting processing of ${this.tasks.length} tasks`,
);
const taskPromises = this.tasks.map(async task => {
try {
await task();
} catch (e) {
this.options.logger.warn(
`[TaskManagement] Failed to process task: ${e.message}`,
);
}
});
await Promise.all(taskPromises);
this.options.logger.info(
`[TaskManagement] Completed processing of all tasks`,
);
}
}

View File

@ -20,23 +20,21 @@ import { GithubCredentials, ScmIntegrations } from '@backstage/integration';
export type GithubInfo = {
credentials: GithubCredentials;
apiBaseUrl: string;
enterprise: string;
enterprise?: string;
organization?: string;
};
export const getGithubInfo = async (config: Config): Promise<GithubInfo> => {
const integrations = ScmIntegrations.fromConfig(config);
const host = config.getString('copilot.host');
const enterprise = config.getString('copilot.enterprise');
const enterprise = config.getOptionalString('copilot.enterprise');
const organization = config.getOptionalString('copilot.organization');
if (!host) {
throw new Error('The host configuration is missing from the config.');
}
if (!enterprise) {
throw new Error('The enterprise configuration is missing from the config.');
}
const githubConfig = integrations.github.byHost(host)?.config;
if (!githubConfig) {
@ -57,5 +55,6 @@ export const getGithubInfo = async (config: Config): Promise<GithubInfo> => {
apiBaseUrl,
credentials,
enterprise,
organization,
};
};

View File

@ -0,0 +1,25 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export async function batchInsertInChunks<T>(
data: T[],
chunkSize: number,
batchInsertFunc: (chunk: T[]) => Promise<void>,
): Promise<void> {
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
await batchInsertFunc(chunk);
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateTime } from 'luxon';
import { MetricDbRow } from '../db/DatabaseHandler';
import {
Metric,
MetricsType,
} from '@backstage-community/plugin-copilot-common';
export function filterNewMetrics(
metrics: Metric[],
lastDay?: string,
): Metric[] {
return metrics
.sort(
(a, b) =>
DateTime.fromISO(a.day).toMillis() - DateTime.fromISO(b.day).toMillis(),
)
.filter(metric => {
const metricDate = DateTime.fromISO(metric.day);
const lastDayDate = lastDay
? DateTime.fromJSDate(new Date(lastDay))
: null;
return !lastDay || (lastDayDate?.isValid && metricDate > lastDayDate);
});
}
export function prepareMetricsForInsert(
metrics: Metric[],
type: MetricsType,
team_name?: string,
): MetricDbRow[] {
return metrics.map(({ breakdown, ...rest }) => ({
...rest,
type,
team_name,
breakdown: JSON.stringify(breakdown),
})) as MetricDbRow[];
}

View File

@ -18,6 +18,7 @@ export interface Breakdown {
export interface Metric {
breakdown: Breakdown[];
day: string;
team_name?: string;
total_acceptances_count: number;
total_active_chat_users: number;
total_active_users: number;
@ -26,11 +27,22 @@ export interface Metric {
total_lines_accepted: number;
total_lines_suggested: number;
total_suggestions_count: number;
type: MetricsType;
}
// @public
export type MetricsType = 'enterprise' | 'organization';
// @public
export interface PeriodRange {
maxDate: string;
minDate: string;
}
// @public
export interface TeamInfo {
id: number;
name: string;
slug: string;
}
```

View File

@ -62,6 +62,13 @@ export interface Breakdown {
suggestions_count: number;
}
/**
* Represents the possible types of metrics data.
*
* @public
*/
export type MetricsType = 'enterprise' | 'organization';
/**
* Represents a detailed breakdown of metrics by language and editor.
*
@ -78,6 +85,18 @@ export interface Metric {
*/
day: string;
/**
* The type of the metrics data.
* Can be 'enterprise', 'organization'.
*/
type: MetricsType;
/**
* The name of the team, applicable when the metric is for a specific team.
* When null, it indicates metrics for all teams, aggregated at the 'enterprise' or 'organization' level.
*/
team_name?: string;
/**
* The total number of suggestions accepted.
*/
@ -135,3 +154,25 @@ export interface PeriodRange {
*/
minDate: string;
}
/**
* Represents information about a team.
*
* @public
*/
export interface TeamInfo {
/**
* The unique identifier of the team.
*/
id: number;
/**
* The slug of the team, used for URL-friendly identifiers.
*/
slug: string;
/**
* The name of the team.
*/
name: string;
}

View File

@ -1,3 +1,7 @@
Aqui está o README ajustado, agora incluindo a nova funcionalidade para selecionar um time e comparar suas métricas com os dados gerais:
---
# GitHub Copilot Plugin
Welcome to the GitHub Copilot Plugin!
@ -6,17 +10,14 @@ Welcome to the GitHub Copilot Plugin!
![home](media/demo.gif)
![enterprise](media/enterprise.png)
![language](media/language.png)
## Overview
The GitHub Copilot Plugin enhances your Backstage experience by providing features tailored to enterprise management.
The GitHub Copilot Plugin enhances your Backstage experience by providing features tailored to both enterprise and organization management.
## Features
- **Enterprise Integration**: Seamlessly integrate enterprise-level functionalities.
- **Enterprise and Organization Integration**: Seamlessly integrate functionalities for GitHub Enterprise and GitHub Organizations.
- **Team Metrics Comparison**: Select a team and compare its metrics with the overall data, offering more insights into individual team performance.
## Setup
@ -24,7 +25,7 @@ The following sections will help you get the GitHub Copilot Plugin setup and run
### Backend
You need to setup the Copilot backend plugin ([copilot-backend](../copilot-backend/README.md)) before you move forward with any of the following steps if you haven't already.
You need to set up the Copilot backend plugin ([copilot-backend](../copilot-backend/README.md)) before you move forward with any of the following steps if you haven't already.
## Installation
@ -41,17 +42,18 @@ To start using the GitHub Copilot Plugin, follow these steps:
**App.tsx**:
```tsx
import { CopilotPage } from '@backstage-community/plugin-copilot';
// Add route
<Route path="/copilot" element={<CopilotPage />} />;
import { CopilotIndexPage } from '@backstage-community/plugin-copilot';
// Add the routes
<Route path="/copilot" element={<CopilotIndexPage />} />;
```
**Root.tsx**:
```tsx
import GithubIcon from '@mui/icons-material/GitHub';
// Add sidebar item
import { CopilotSidebar } from '@backstage-community/plugin-copilot';
// Add the copilot sidebar
<SidebarScrollWrapper>
<SidebarItem icon={GithubIcon} to="copilot" text="Copilot" />
<CopilotSidebar />
</SidebarScrollWrapper>;
```

View File

@ -0,0 +1,34 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Configuration interface for the GitHub Copilot Plugin.
*/
export interface Config {
/** Configuration options for the GitHub Copilot Plugin */
copilot?: {
/**
* The name of the GitHub enterprise.
* @visibility frontend
*/
enterprise?: string;
/**
* The name of the GitHub organization.
* @visibility frontend
*/
organization?: string;
};
}

View File

@ -15,7 +15,7 @@
*/
import React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { CopilotPage, copilotPlugin } from '../src';
import { copilotPlugin, CopilotSidebar, CopilotIndexPage } from '../src';
import {
UnifiedThemeProvider,
themes as builtinThemes,
@ -23,7 +23,6 @@ import {
import DarkIcon from '@mui/icons-material/Brightness2';
import LightIcon from '@mui/icons-material/WbSunny';
import { AppTheme } from '@backstage/core-plugin-api';
import { GitHubIcon } from '@backstage/core-components';
const customThemes: AppTheme[] = [
{
@ -49,11 +48,9 @@ const customThemes: AppTheme[] = [
createDevApp()
.addThemes(customThemes)
.registerPlugin(copilotPlugin)
.registerPlugin()
.addSidebarItem(<CopilotSidebar />)
.addPage({
element: <CopilotPage />,
title: 'Copilot Page',
icon: GitHubIcon,
element: <CopilotIndexPage />,
path: '/copilot',
})
.render();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 36 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

View File

@ -46,10 +46,13 @@
"@mui/x-charts": "^7.6.1",
"luxon": "^3.5.0",
"react-use": "^17.3.1",
"simple-date-range-calendar": "^1.0.10"
"simple-date-range-calendar": "^2.0.6"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0"
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^18.0.2",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0"
},
"devDependencies": {
"@backstage/cli": "^0.28.0",
@ -65,6 +68,8 @@
"msw": "^1.0.0"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}

View File

@ -8,16 +8,22 @@
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { SubRouteRef } from '@backstage/core-plugin-api';
// @public
export const CopilotPage: () => JSX_2.Element;
export const CopilotIndexPage: () => JSX_2.Element;
// @public
export const copilotPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
copilot: RouteRef<undefined>;
enterprise: SubRouteRef<undefined>;
organization: SubRouteRef<undefined>;
},
{},
{}
>;
// @public
export const CopilotSidebar: () => JSX_2.Element;
```

View File

@ -17,6 +17,7 @@
import { createApiRef } from '@backstage/core-plugin-api';
import {
Metric,
MetricsType,
PeriodRange,
} from '@backstage-community/plugin-copilot-common';
@ -25,6 +26,16 @@ export const copilotApiRef = createApiRef<CopilotApi>({
});
export interface CopilotApi {
getMetrics(startDate: Date, endDate: Date): Promise<Metric[]>;
periodRange(): Promise<PeriodRange>;
getMetrics(
startDate: Date,
endDate: Date,
type: MetricsType,
team?: string,
): Promise<Metric[]>;
fetchTeams(
startDate: Date,
endDate: Date,
type: MetricsType,
): Promise<string[]>;
periodRange(type: MetricsType): Promise<PeriodRange>;
}

View File

@ -19,6 +19,7 @@ import { ResponseError } from '@backstage/errors';
import { CopilotApi } from './CopilotApi';
import {
Metric,
MetricsType,
PeriodRange,
} from '@backstage-community/plugin-copilot-common';
import { DateTime } from 'luxon';
@ -31,7 +32,12 @@ export class CopilotClient implements CopilotApi {
},
) {}
public async getMetrics(startDate: Date, endDate: Date): Promise<Metric[]> {
public async getMetrics(
startDate: Date,
endDate: Date,
type: MetricsType,
team?: string,
): Promise<Metric[]> {
const queryString = new URLSearchParams();
queryString.append(
@ -43,17 +49,49 @@ export class CopilotClient implements CopilotApi {
DateTime.fromJSDate(endDate).toFormat('yyyy-MM-dd'),
);
queryString.append('type', type);
if (team) {
queryString.append('team', team);
}
const urlSegment = `metrics?${queryString}`;
return await this.get<Metric[]>(urlSegment);
}
public async periodRange() {
const urlSegment = `metrics/period-range`;
public async periodRange(type: MetricsType): Promise<PeriodRange> {
const queryString = new URLSearchParams();
queryString.append('type', type);
const urlSegment = `metrics/period-range?${queryString}`;
return await this.get<PeriodRange>(urlSegment);
}
public async fetchTeams(
startDate: Date,
endDate: Date,
type: MetricsType,
): Promise<string[]> {
const queryString = new URLSearchParams();
queryString.append(
'startDate',
DateTime.fromJSDate(startDate).toFormat('yyyy-MM-dd'),
);
queryString.append(
'endDate',
DateTime.fromJSDate(endDate).toFormat('yyyy-MM-dd'),
);
queryString.append('type', type);
const urlSegment = `teams?${queryString}`;
return await this.get<string[]>(urlSegment);
}
private async get<T>(path: string): Promise<T> {
const baseUrl = await this.options.discoveryApi.getBaseUrl('copilot');
const response = await this.options.fetchApi.fetch(`${baseUrl}/${path}`);

View File

@ -18,12 +18,17 @@ import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import Icon from '@mui/material/Icon';
import Typography from '@mui/material/Typography';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { styled } from '@mui/material/styles';
import { InfoCard } from '@backstage/core-components';
import { DateTime } from 'luxon';
type CardItemProps = {
team?: string;
title: string;
value: number | string;
primaryValue: number | string;
secondaryValue?: number | string;
startDate: Date;
endDate: Date;
icon: ElementType<ReactNode>;
@ -31,17 +36,6 @@ type CardItemProps = {
const format = 'dd/MM/yyyy';
const MainBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
borderRadius: 25,
minWidth: 256,
minHeight: 138,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
}));
const TextGroup = styled(Box)(({ theme }) => ({
padding: theme.spacing(1),
}));
@ -52,14 +46,16 @@ const FooterText = styled(Typography)(({ theme }) => ({
}));
export const Card = ({
team,
title,
value,
primaryValue,
secondaryValue,
startDate,
endDate,
icon,
}: CardItemProps) => {
const message = useMemo(() => {
if (value === 'N/A') return `No data available`;
if (primaryValue === 'N/A') return `No data available`;
const endDateTime = DateTime.fromJSDate(endDate);
const now = DateTime.now();
@ -77,19 +73,38 @@ export const Card = ({
return `From ${DateTime.fromJSDate(startDate).toFormat(
format,
)} to ${endDateTime.toFormat(format)}`;
}, [value, startDate, endDate]);
}, [primaryValue, startDate, endDate]);
return (
<MainBox>
<InfoCard divider={false}>
<Box display="flex" alignItems="center">
<Icon component={icon} />
<TextGroup>
<Typography color="textSecondary" variant="subtitle2" component="h2">
<Typography color="primary" variant="subtitle2" component="h2">
{title}
</Typography>
<Typography variant="h3" component="h2">
{value}
</Typography>
<Stack direction="row" gap={2} alignItems="flex-end">
<Tooltip title={team && secondaryValue ? team : 'Overall'}>
<Typography
variant="h3"
component="h2"
color="textPrimary"
mb={0}
>
{primaryValue}
</Typography>
</Tooltip>
<Tooltip title={team && secondaryValue && 'Overall'}>
<Typography
variant="h6"
component="h5"
color="textSecondary"
mb={0}
>
{secondaryValue}
</Typography>
</Tooltip>
</Stack>
</TextGroup>
</Box>
<Divider />
@ -98,6 +113,6 @@ export const Card = ({
{message}
</FooterText>
</Box>
</MainBox>
</InfoCard>
);
};

View File

@ -0,0 +1,150 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AssessmentIcon from '@mui/icons-material/Assessment';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import CodeIcon from '@mui/icons-material/Code';
import Box from '@mui/material/Box';
import { Card } from './Card';
import { CardsProps } from '../../types';
import { styled } from '@mui/material/styles';
import { Metric } from '@backstage-community/plugin-copilot-common';
const CardBox = styled(Box)({
flex: '1 1 calc(50% - 10px)',
minWidth: 300,
maxWidth: 'calc(50% - 10px)',
boxSizing: 'border-box',
});
export const DashboardCards = ({
team,
metrics,
metricsByTeam,
startDate,
endDate,
}: PropsWithChildren<CardsProps>) => {
const sumProperty = (data: any[], property: string) =>
data.reduce((acc, item) => acc + (item[property] || 0), 0);
const calculateAcceptanceRate = (metricsData: Metric[]) => {
const totalSuggested = sumProperty(metricsData, 'total_lines_suggested');
const totalAccepted = sumProperty(metricsData, 'total_lines_accepted');
return totalSuggested > 0
? ((totalAccepted / totalSuggested) * 100).toFixed(2).concat('%')
: 'N/A';
};
const overallMetrics = {
acceptanceRate: calculateAcceptanceRate(metrics),
suggestionsCount:
metrics.length > 0
? sumProperty(metrics, 'total_suggestions_count')
: 'N/A',
acceptancesCount:
metrics.length > 0
? sumProperty(metrics, 'total_acceptances_count')
: 'N/A',
linesAccepted:
metrics.length > 0 ? sumProperty(metrics, 'total_lines_accepted') : 'N/A',
};
const teamMetrics = {
acceptanceRate: calculateAcceptanceRate(metricsByTeam),
suggestionsCount:
metricsByTeam.length > 0
? sumProperty(metricsByTeam, 'total_suggestions_count')
: 'N/A',
acceptancesCount:
metricsByTeam.length > 0
? sumProperty(metricsByTeam, 'total_acceptances_count')
: 'N/A',
linesAccepted:
metricsByTeam.length > 0
? sumProperty(metricsByTeam, 'total_lines_accepted')
: 'N/A',
};
return (
<Box display="flex" flexWrap="wrap" gap={1} justifyContent="space-between">
<CardBox>
<Card
team={team}
title="Acceptance Rate Average"
primaryValue={
team ? teamMetrics.acceptanceRate : overallMetrics.acceptanceRate
}
secondaryValue={team ? overallMetrics.acceptanceRate : undefined}
startDate={startDate}
endDate={endDate}
icon={() => (
<CheckCircleIcon style={{ color: '#4CAF50' }} fontSize="large" />
)}
/>
</CardBox>
<CardBox>
<Card
team={team}
title="Nº of Suggestions"
primaryValue={
team
? teamMetrics.suggestionsCount
: overallMetrics.suggestionsCount
}
secondaryValue={team ? overallMetrics.suggestionsCount : undefined}
startDate={startDate}
endDate={endDate}
icon={() => (
<AssessmentIcon style={{ color: '#2196F3' }} fontSize="large" />
)}
/>
</CardBox>
<CardBox>
<Card
team={team}
title="Nº of Accepted Prompts"
primaryValue={
team
? teamMetrics.acceptancesCount
: overallMetrics.acceptancesCount
}
secondaryValue={team ? overallMetrics.acceptancesCount : undefined}
startDate={startDate}
endDate={endDate}
icon={() => (
<ThumbUpIcon style={{ color: '#FF9800' }} fontSize="large" />
)}
/>
</CardBox>
<CardBox>
<Card
team={team}
title="Nº Lines of Code Accepted"
primaryValue={
team ? teamMetrics.linesAccepted : overallMetrics.linesAccepted
}
secondaryValue={team ? overallMetrics.linesAccepted : undefined}
startDate={startDate}
endDate={endDate}
icon={() => (
<CodeIcon style={{ color: '#FF5722' }} fontSize="large" />
)}
/>
</CardBox>
</Box>
);
};

View File

@ -1,101 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AssessmentIcon from '@mui/icons-material/Assessment';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
import CodeIcon from '@mui/icons-material/Code';
import Box from '@mui/material/Box';
import { Card } from './Card';
import { CardsProps } from '../../types';
import { styled } from '@mui/material/styles';
const CardBox = styled(Box)(() => ({
flex: '1 1 0',
}));
export const EnterpriseCards = ({
metrics,
startDate,
endDate,
}: PropsWithChildren<CardsProps>) => {
const total_suggestions_count = metrics.reduce((acc, m) => {
return acc + m.total_suggestions_count;
}, 0);
const total_acceptances_count = metrics.reduce((acc, m) => {
return acc + m.total_acceptances_count;
}, 0);
const total_lines_accepted = metrics.reduce((acc, m) => {
return acc + m.total_lines_accepted;
}, 0);
return (
<Box display="flex" flexWrap="wrap" gap={2}>
<CardBox flex={1} minWidth="384px">
<Card
title="Acceptance Rate Average"
value={
metrics.length
? ((total_acceptances_count / total_suggestions_count) * 100)
.toFixed(2)
.concat('%')
: 'N/A'
}
startDate={startDate}
endDate={endDate}
icon={() => (
<CheckCircleIcon style={{ color: '#4CAF50' }} fontSize="large" />
)}
/>
</CardBox>
<CardBox flex={1} minWidth="384px">
<Card
title="Nº of Suggestions"
value={metrics.length ? total_suggestions_count : 'N/A'}
startDate={startDate}
endDate={endDate}
icon={() => (
<AssessmentIcon style={{ color: '#2196F3' }} fontSize="large" />
)}
/>
</CardBox>
<CardBox flex={1} minWidth="384px">
<Card
title="Nº of Accepted Prompts"
value={metrics.length ? total_acceptances_count : 'N/A'}
startDate={startDate}
endDate={endDate}
icon={() => (
<ThumbUpIcon style={{ color: '#FF9800' }} fontSize="large" />
)}
/>
</CardBox>
<CardBox flex={1} minWidth="384px">
<Card
title="Nº Lines of Code Accepted"
value={metrics.length ? total_lines_accepted : 'N/A'}
startDate={startDate}
endDate={endDate}
icon={() => (
<CodeIcon style={{ color: '#FF5722' }} fontSize="large" />
)}
/>
</CardBox>
</Box>
);
};

View File

@ -21,24 +21,39 @@ import { getLanguageStats } from '../../utils';
import { CardsProps } from '../../types';
import { styled } from '@mui/material/styles';
const CardBox = styled(Box)(() => ({
flex: '1 1 0',
maxWidth: 354,
}));
const CardBox = styled(Box)({
flex: '1 1 calc(50% - 10px)',
minWidth: 300,
maxWidth: 'calc(50% - 10px)',
boxSizing: 'border-box',
});
export const LanguageCards = ({
team,
metrics,
metricsByTeam,
startDate,
endDate,
}: PropsWithChildren<CardsProps>) => {
const languageStats = getLanguageStats(metrics);
const overallLanguageStats =
metrics.length > 0 ? getLanguageStats(metrics) : [];
const teamLanguageStats =
metricsByTeam.length > 0 ? getLanguageStats(metricsByTeam) : [];
return (
<Box display="flex" flexWrap="wrap" gap={2}>
<Box display="flex" flexWrap="wrap" gap={1} justifyContent="space-between">
<CardBox>
<Card
team={team}
title="Nº of Languages"
value={metrics.length ? languageStats.length : 'N/A'}
primaryValue={
team
? teamLanguageStats.length || 'N/A'
: overallLanguageStats.length || 'N/A'
}
secondaryValue={
team ? overallLanguageStats.length || 'N/A' : undefined
}
startDate={startDate}
endDate={endDate}
icon={() => (

View File

@ -13,5 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EnterpriseCards } from './EnterpriseCards';
export { DashboardCards } from './DashboardCards';
export { LanguageCards } from './LanguageCards';

View File

@ -18,23 +18,22 @@ import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
import { InfoCard } from '@backstage/core-components';
type ChartProps = {
title: string;
};
const MainBox = styled(Box)(({ theme }) => ({
const InfoCardStyled = styled(InfoCard)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
borderRadius: 25,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
width: '100%',
}));
export const Chart = ({ title, children }: PropsWithChildren<ChartProps>) => {
return (
<MainBox>
<InfoCardStyled divider={false} noPadding>
<Box display="flex" alignItems="center">
<Typography variant="h3" component="h2">
{title}
@ -42,6 +41,6 @@ export const Chart = ({ title, children }: PropsWithChildren<ChartProps>) => {
</Box>
<Divider />
{children}
</MainBox>
</InfoCardStyled>
);
};

View File

@ -0,0 +1,134 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import { BarChart, LineChart } from '@mui/x-charts';
import { Chart } from './Chart';
import { ChartsProps } from '../../types';
import { DateTime } from 'luxon';
import {
createAcceptanceRateSeries,
createTotalSuggestionsAndAcceptancesSeries,
createTotalLinesSuggestedAndAcceptedSeries,
createTotalActiveUsersSeries,
} from './seriesUtils';
const MainBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
export const DashboardCharts = ({
team,
metrics,
metricsByTeam,
}: PropsWithChildren<ChartsProps>) => (
<MainBox>
<Chart title="Acceptance Rate %">
<LineChart
xAxis={[
{
id: 'days',
data: metrics.map(x => new Date(x.day)),
scaleType: 'point',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={createAcceptanceRateSeries(metrics, metricsByTeam, team)}
height={300}
/>
</Chart>
<Chart title="Total Suggestions Count | Total Acceptances Count">
<LineChart
xAxis={[
{
id: 'days',
data: metrics.map(x => new Date(x.day)),
scaleType: 'point',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={createTotalSuggestionsAndAcceptancesSeries(
metrics,
metricsByTeam,
team,
)}
height={300}
/>
</Chart>
<Chart title="Total Lines Suggested | Total Lines Accepted">
<LineChart
xAxis={[
{
id: 'days',
data: metrics.map(x => new Date(x.day)),
scaleType: 'point',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={createTotalLinesSuggestedAndAcceptedSeries(
metrics,
metricsByTeam,
team,
)}
height={300}
/>
</Chart>
<Chart title="Total Active Users">
<BarChart
xAxis={[
{
data: metrics.map(x => new Date(x.day)),
scaleType: 'band',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={createTotalActiveUsersSeries(metrics, metricsByTeam, team)}
height={300}
/>
</Chart>
</MainBox>
);

View File

@ -1,156 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import { BarChart, LineChart } from '@mui/x-charts';
import { Chart } from './Chart';
import { ChartsProps } from '../../types';
import { DateTime } from 'luxon';
const MainBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
export const EnterpriseCharts = ({
metrics,
}: PropsWithChildren<ChartsProps>) => {
return (
<MainBox>
<Chart title="Acceptance Rate %">
<LineChart
xAxis={[
{
id: 'days',
data: metrics.map(x => new Date(x.day)),
scaleType: 'point',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={[
{
id: 'total_lines_suggested',
label: 'Acceptance Rate',
valueFormatter: v => v?.toFixed(2).concat('%') ?? 'N/A',
data: metrics.map(
x => (x.total_lines_accepted / x.total_lines_suggested) * 100,
),
},
]}
height={300}
/>
</Chart>
<Chart title="Total Suggestions Count | Total Acceptances Count">
<LineChart
xAxis={[
{
id: 'days',
data: metrics.map(x => new Date(x.day)),
scaleType: 'point',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={[
{
id: 'total_suggestions_count',
label: 'Total Suggestions',
data: metrics.map(x => x.total_suggestions_count),
},
{
id: 'total_acceptances_count',
label: 'Total Acceptances',
data: metrics.map(x => x.total_acceptances_count),
},
]}
height={300}
/>
</Chart>
<Chart title="Total Lines Suggested | Total Lines Accepted">
<LineChart
xAxis={[
{
id: 'days',
data: metrics.map(x => new Date(x.day)),
scaleType: 'point',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={[
{
id: 'total_lines_suggested',
label: 'Total Lines Suggested',
data: metrics.map(x => x.total_lines_suggested),
},
{
id: 'total_lines_accepted',
label: 'Total Lines Accepted',
data: metrics.map(x => x.total_lines_accepted),
},
]}
height={300}
/>
</Chart>
<Chart title="Total Active Users">
<BarChart
xAxis={[
{
data: metrics.map(x => new Date(x.day)),
scaleType: 'band',
valueFormatter: date =>
DateTime.fromJSDate(date).toFormat('dd-MM-yy'),
},
]}
bottomAxis={{
tickLabelStyle: {
angle: 30,
textAnchor: 'start',
},
}}
series={[
{
label: 'Total Active Users',
data: metrics.map(x => x.total_active_users),
},
]}
height={300}
/>
</Chart>
</MainBox>
);
};

View File

@ -18,7 +18,7 @@ import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import { PieChart } from '@mui/x-charts';
import { Chart } from './Chart';
import { LanguagesBreakdownTable } from '../Table/LanguagesBreakdownTable';
import { LanguagesComparisonTables } from '../Table/LanguagesComparisonTables';
import {
getLanguageStats,
getTopLanguagesByAcceptanceRate,
@ -36,55 +36,113 @@ const RowBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(2),
width: '100%',
}));
export const LanguageCharts = ({ metrics }: PropsWithChildren<ChartsProps>) => {
const languageStats = getLanguageStats(metrics);
const Fieldset = styled('fieldset')(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
marginBottom: theme.spacing(2),
}));
const topFiveLanguagesByAcceptedPrompts = getTopLanguagesByAcceptedPrompts(
metrics,
5,
);
const topFiveLanguagesByAcceptanceRate = getTopLanguagesByAcceptanceRate(
metrics,
const Legend = styled('legend')(({ theme }) => ({
fontSize: theme.typography.h6.fontSize,
padding: `0 ${theme.spacing(1)}`,
}));
const renderPieCharts = (promptsData: any[], rateData: any[]) => (
<RowBox>
<Chart title="Top 5 Languages By Accepted Prompts">
<PieChart
margin={{ right: 200 }}
series={[
{
data: promptsData.map(lan => ({
id: lan.language,
value: lan.totalAcceptances,
label: `${lan.language}: ${lan.totalAcceptances}`,
})),
highlightScope: { fade: 'global', highlight: 'item' },
faded: { innerRadius: 30, additionalRadius: -30, color: 'gray' },
},
]}
height={300}
/>
</Chart>
<Chart title="Top 5 Languages By Acceptance Rate">
<PieChart
margin={{ right: 200 }}
series={[
{
data: rateData.map(lan => ({
id: lan.language,
value: lan.acceptanceRate * 100,
label: `${lan.language}: ${(lan.acceptanceRate * 100).toFixed(
2,
)}%`,
})),
highlightScope: { fade: 'global', highlight: 'item' },
faded: { innerRadius: 30, additionalRadius: -30, color: 'gray' },
valueFormatter: item => `${item.value.toFixed(2)}%`,
},
]}
height={300}
/>
</Chart>
</RowBox>
);
export const LanguageCharts = ({
team,
metrics,
metricsByTeam,
}: PropsWithChildren<ChartsProps>) => {
const languageStats = getLanguageStats(metrics);
const languageStatsByTeam = getLanguageStats(metricsByTeam);
const topFiveLanguagesByAcceptedPromptsOverall =
getTopLanguagesByAcceptedPrompts(metrics, 5);
const topFiveLanguagesByAcceptanceRateOverall =
getTopLanguagesByAcceptanceRate(metrics, 5);
const topFiveLanguagesByAcceptedPromptsTeam =
getTopLanguagesByAcceptedPrompts(metricsByTeam, 5);
const topFiveLanguagesByAcceptanceRateTeam = getTopLanguagesByAcceptanceRate(
metricsByTeam,
5,
);
return (
<MainBox>
<RowBox>
<Chart title="Top 5 Languages By Accepted Prompts">
<PieChart
series={[
{
data: topFiveLanguagesByAcceptedPrompts.map(lan => ({
id: lan.language,
value: lan.totalAcceptances,
label: lan.language,
})),
},
]}
height={300}
/>
</Chart>
<Chart title="Top 5 Languages By Acceptance Rate">
<PieChart
series={[
{
data: topFiveLanguagesByAcceptanceRate.map(lan => ({
id: lan.language,
value: lan.acceptanceRate * 100,
label: lan.language,
})),
valueFormatter: item => `${item.value.toFixed(2)}%`,
},
]}
height={300}
/>
</Chart>
</RowBox>
{team && (
<Fieldset>
<Legend>{team}</Legend>
{renderPieCharts(
topFiveLanguagesByAcceptedPromptsTeam,
topFiveLanguagesByAcceptanceRateTeam,
)}
</Fieldset>
)}
{team && (
<Fieldset>
<Legend>Overall</Legend>
{renderPieCharts(
topFiveLanguagesByAcceptedPromptsOverall,
topFiveLanguagesByAcceptanceRateOverall,
)}
</Fieldset>
)}
{!team &&
renderPieCharts(
topFiveLanguagesByAcceptedPromptsOverall,
topFiveLanguagesByAcceptanceRateOverall,
)}
<Chart title="Languages Breakdown">
<LanguagesBreakdownTable rows={languageStats} />
<LanguagesComparisonTables
team={team}
overallRows={languageStats}
teamRows={languageStatsByTeam}
/>
</Chart>
</MainBox>
);

View File

@ -13,5 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EnterpriseCharts } from './EnterpriseCharts';
export { DashboardCharts } from './DashboardCharts';
export { LanguageCharts } from './LanguageCharts';

View File

@ -0,0 +1,182 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Metric } from '@backstage-community/plugin-copilot-common';
const addSeriesIfTeam = (
series: any[],
condition: boolean,
seriesConfig: any,
) => {
if (condition) {
return series.push(seriesConfig);
}
return series;
};
export const createAcceptanceRateSeries = (
metrics: Metric[],
metricsByTeam: Metric[],
team?: string,
) => {
const series = [
{
id: 'total_lines_suggested',
label: 'Acceptance Rate (Overall)',
valueFormatter: (v: number | null) => v?.toFixed(2).concat('%') ?? 'N/A',
data: metrics.map(
x => (x.total_lines_accepted / x.total_lines_suggested) * 100,
),
},
];
addSeriesIfTeam(series, !!team, {
id: 'total_lines_suggested_by_team',
label: `Acceptance Rate (${team})`,
valueFormatter: (v: number | null) => v?.toFixed(2).concat('%') ?? 'N/A',
data: metrics.map(metric => {
const metricByteam = metricsByTeam.find(
teamMetric => teamMetric.day === metric.day,
);
if (!metricByteam) return null;
return (
(metricByteam.total_lines_accepted /
metricByteam.total_lines_suggested) *
100
);
}),
});
return series;
};
export const createTotalSuggestionsAndAcceptancesSeries = (
metrics: Metric[],
metricsByTeam: Metric[],
team?: string,
) => {
const series = [
{
id: 'total_suggestions_count_general',
label: 'Total Suggestions (Overall)',
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(metric => metric.total_suggestions_count),
},
{
id: 'total_acceptances_count_general',
label: 'Total Acceptances (Overall)',
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(metric => metric.total_acceptances_count),
},
];
addSeriesIfTeam(series, !!team, {
id: 'total_suggestions_count_by_team',
label: `Total Suggestions (${team})`,
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(
metric =>
metricsByTeam.find(teamMetric => teamMetric.day === metric.day)
?.total_suggestions_count ?? null,
),
});
addSeriesIfTeam(series, !!team, {
id: 'total_acceptances_count_by_team',
label: `Total Acceptances (${team})`,
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(
metric =>
metricsByTeam.find(teamMetric => teamMetric.day === metric.day)
?.total_acceptances_count ?? null,
),
});
return series;
};
export const createTotalLinesSuggestedAndAcceptedSeries = (
metrics: Metric[],
metricsByTeam: Metric[],
team?: string,
) => {
const series = [
{
id: 'total_lines_suggested',
label: 'Total Lines Suggested (Overall)',
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(x => x.total_lines_suggested),
},
{
id: 'total_lines_accepted',
label: 'Total Lines Accepted (Overall)',
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(x => x.total_lines_accepted),
},
];
addSeriesIfTeam(series, !!team, {
id: 'total_lines_suggested_by_team',
label: `Total Lines Suggested (${team})`,
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(
metric =>
metricsByTeam.find(teamMetric => teamMetric.day === metric.day)
?.total_lines_suggested ?? null,
),
});
addSeriesIfTeam(series, !!team, {
id: 'total_lines_accepted_by_team',
label: `Total Lines Accepted (${team})`,
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(
metric =>
metricsByTeam.find(teamMetric => teamMetric.day === metric.day)
?.total_lines_accepted ?? null,
),
});
return series;
};
export const createTotalActiveUsersSeries = (
metrics: Metric[],
metricsByTeam: Metric[],
team?: string,
) => {
const series = [
{
label: 'Total Active Users (Overall)',
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(x => x.total_active_users),
},
];
addSeriesIfTeam(series, !!team, {
id: 'total_active_users_by_team',
label: `Total Active Users (${team})`,
valueFormatter: (v: number | null) => v?.toString() ?? 'N/A',
data: metrics.map(
metric =>
metricsByTeam.find(teamMetric => teamMetric.day === metric.day)
?.total_active_users ?? null,
),
});
return series;
};

View File

@ -0,0 +1,82 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import OutlinedInput from '@mui/material/OutlinedInput';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import { FilterProps } from '../../types';
import { styled } from '@mui/material/styles';
const InputStyled = styled(OutlinedInput)(({ theme }) => ({
borderRadius: 4,
backgroundColor: theme.palette.background.paper,
}));
export function SelectTeamFilter({ options, team, setTeam }: FilterProps) {
const handleChange = (event: SelectChangeEvent<string>) => {
setTeam(event.target.value as string);
};
const handleClear = () => {
setTeam(undefined);
};
const getPlaceholderMessage = () => {
const teamCount = options.length ?? 0;
if (teamCount === 0) {
return 'No teams available for selection';
}
if (teamCount === 1) {
return '1 team available for selection';
}
return `${teamCount} teams available for selection`;
};
return (
<Select
value={team ?? ''}
onChange={handleChange}
input={
<InputStyled
endAdornment={
team && (
<IconButton onClick={handleClear} sx={{ mr: 2 }}>
<CloseIcon />
</IconButton>
)
}
/>
}
inputProps={{ 'aria-label': 'Team selection' }}
fullWidth
displayEmpty
renderValue={selected => {
if (!selected) {
return <em>{getPlaceholderMessage()}</em>;
}
return selected;
}}
>
{options?.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
);
}

View File

@ -0,0 +1,16 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { SelectTeamFilter } from './SelectTeamFilter';

View File

@ -13,18 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import React, { useMemo } from 'react';
import Box from '@mui/material/Box';
import { styled, useTheme } from '@mui/material/styles';
import Stack from '@mui/material/Stack';
import { styled } from '@mui/material/styles';
import { Calendar } from 'simple-date-range-calendar';
import { useMetrics } from '../../hooks';
import { CardsProps, ChartsProps } from '../../types';
import { Progress } from '@backstage/core-components';
import { useSharedDateRange } from '../../contexts';
import { useMetrics, useMetricsByTeam, useTeams } from '../../hooks';
import { CardsProps, ChartsProps, FilterProps } from '../../types';
import { InfoCard, Progress } from '@backstage/core-components';
import { useSharedDateRange, useSharedTeam } from '../../contexts';
type MetricsProps = {
Cards: React.ElementType<CardsProps>;
Charts: React.ElementType<ChartsProps>;
Filters: React.ElementType<FilterProps>;
};
const MainBox = styled(Box)(({ theme }) => ({
@ -33,37 +35,72 @@ const MainBox = styled(Box)(({ theme }) => ({
gap: theme.spacing(2),
}));
export const RenderCharts = ({ Charts }: Omit<MetricsProps, 'Cards'>) => {
export const RenderFilters = ({
Filters,
}: Omit<Omit<MetricsProps, 'Cards'>, 'Charts'>) => {
const [state] = useSharedDateRange();
const { items, loading } = useMetrics(state.startDate, state.endDate);
const [team, setTeam] = useSharedTeam();
const data = useTeams(state.startDate, state.endDate);
const options = useMemo(
() => data.items?.map(x => ({ label: x, value: x })) ?? [],
[data.items],
);
if (loading) {
if (!Filters) return null;
if (data.loading) {
return <Progress />;
}
return <Charts metrics={items || []} />;
return <Filters options={options} team={team} setTeam={setTeam} />;
};
export const RenderCards = ({ Cards }: Omit<MetricsProps, 'Charts'>) => {
export const RenderCharts = ({
Charts,
}: Omit<Omit<MetricsProps, 'Cards'>, 'Filters'>) => {
const [state] = useSharedDateRange();
const { items, loading } = useMetrics(state.startDate, state.endDate);
const [team] = useSharedTeam();
const data = useMetrics(state.startDate, state.endDate);
const dataPerTeam = useMetricsByTeam(state.startDate, state.endDate);
if (loading) {
if (data.loading || dataPerTeam.loading) {
return <Progress />;
}
return (
<Charts
team={team}
metrics={data.items ?? []}
metricsByTeam={dataPerTeam.items ?? []}
/>
);
};
export const RenderCards = ({
Cards,
}: Omit<Omit<MetricsProps, 'Charts'>, 'Filters'>) => {
const [state] = useSharedDateRange();
const [team] = useSharedTeam();
const data = useMetrics(state.startDate, state.endDate);
const dataPerTeam = useMetricsByTeam(state.startDate, state.endDate);
if (data.loading || dataPerTeam.loading) {
return <Progress />;
}
return (
<Cards
team={team}
startDate={state.startDate}
endDate={state.endDate}
metrics={items || []}
metrics={data.items ?? []}
metricsByTeam={dataPerTeam.items ?? []}
/>
);
};
export const Metrics = ({ Cards, Charts }: MetricsProps) => {
export const Metrics = ({ Cards, Charts, Filters }: MetricsProps) => {
const [state, setState] = useSharedDateRange();
const theme = useTheme();
const onDateRangeIsSelected = (start: Date, end: Date) => {
setState({ startDate: start, endDate: end });
@ -72,15 +109,20 @@ export const Metrics = ({ Cards, Charts }: MetricsProps) => {
return (
<MainBox>
<Box display="flex" gap={2}>
<Box flex={1} maxWidth={300}>
<Calendar
theme={theme.palette.mode}
startDate={state.startDate}
endDate={state.endDate}
onDateRangeIsSelected={onDateRangeIsSelected}
/>
<Box flex={1} maxWidth={296}>
<InfoCard divider={false} noPadding>
<Calendar
styles={{ borderRadius: 4, width: 296 }}
startDate={state.startDate}
endDate={state.endDate}
onDateRangeIsSelected={onDateRangeIsSelected}
/>
</InfoCard>
</Box>
<Box flex={2}>
<Box flex={1}>
<Stack pb={1.5}>
<RenderFilters Filters={Filters} />
</Stack>
<RenderCards Cards={Cards} />
</Box>
</Box>

View File

@ -0,0 +1,28 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Routes, Route } from 'react-router';
import { HomePage } from './HomePage';
import { EnterprisePage } from './EnterprisePage';
import { OrganizationPage } from './OrganizationPage';
export const CopilotIndexPage = () => (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/enterprise" element={<EnterprisePage />} />
<Route path="/organization" element={<OrganizationPage />} />
</Routes>
);

View File

@ -14,64 +14,30 @@
* limitations under the License.
*/
import React from 'react';
import {
Header,
Page,
Content,
TabbedCard,
CardTab,
} from '@backstage/core-components';
import { Metrics } from '../Metrics';
import { LanguageCards, EnterpriseCards } from '../Cards';
import { LanguageCharts, EnterpriseCharts } from '../Charts';
import useObservable from 'react-use/lib/useObservable';
import { themes } from '@backstage/theme';
import { appThemeApiRef, useApi } from '@backstage/core-plugin-api';
import { ThemeProvider } from '@emotion/react';
import { styled } from '@mui/material/styles';
import { SharedDateRangeProvider } from '../../contexts';
import React, { PropsWithChildren } from 'react';
import { Header, Page, Content } from '@backstage/core-components';
import { SharedDateRangeProvider, SharedTeamProvider } from '../../contexts';
const StyledContent = styled(Content)(({ theme }) => ({
margin: `${theme.spacing(0)} !important`,
padding: `${theme.spacing(0)} !important`,
'& > div': {
backgroundColor: theme.palette.background.default,
},
}));
export const CopilotPage = () => {
const appThemeApi = useApi(appThemeApiRef);
const activeThemeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)',
).matches;
const mode = activeThemeId ?? (prefersDarkMode ? 'dark' : 'light');
const theme = (mode === 'dark' ? themes.dark : themes.light).getTheme('v5')!;
return (
<ThemeProvider theme={theme}>
<Page themeId="tool">
<Header
title="Copilot Adoption"
subtitle="Exploring the Impact and Integration of AI Assistance in Development Workflows"
/>
<StyledContent>
<SharedDateRangeProvider>
<TabbedCard>
<CardTab label="Enterprise">
<Metrics Cards={EnterpriseCards} Charts={EnterpriseCharts} />
</CardTab>
<CardTab label="Languages">
<Metrics Cards={LanguageCards} Charts={LanguageCharts} />
</CardTab>
</TabbedCard>
</SharedDateRangeProvider>
</StyledContent>
</Page>
</ThemeProvider>
);
type CopilotPageProps = {
title: string;
subtitle: string;
themeId: string;
};
export function CopilotPage({
children,
themeId,
title,
subtitle,
}: PropsWithChildren<CopilotPageProps>): React.JSX.Element {
return (
<Page themeId={themeId}>
<Header title={title} subtitle={subtitle} />
<Content>
<SharedDateRangeProvider>
<SharedTeamProvider>{children}</SharedTeamProvider>
</SharedDateRangeProvider>
</Content>
</Page>
);
}

View File

@ -0,0 +1,62 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { TabbedCard, CardTab } from '@backstage/core-components';
import { Metrics } from '../Metrics';
import { LanguageCards, DashboardCards } from '../Cards';
import { LanguageCharts, DashboardCharts } from '../Charts';
import { SelectTeamFilter } from '../Filters';
import { CopilotPage } from './CopilotPage';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
export const EnterprisePage = (): React.JSX.Element => {
const navigate = useNavigate();
const configApi = useApi(configApiRef);
useEffect(() => {
const enterpriseKey = configApi.getOptionalString('copilot.enterprise');
if (!enterpriseKey) {
navigate('/copilot');
}
}, [navigate, configApi]);
return (
<CopilotPage
title="Enterprise Metrics Overview"
subtitle="Track AI Adoption and Performance Metrics Across Your Enterprise"
themeId="tool"
>
<TabbedCard>
<CardTab label="Enterprise">
<Metrics
Filters={SelectTeamFilter}
Cards={DashboardCards}
Charts={DashboardCharts}
/>
</CardTab>
<CardTab label="Languages">
<Metrics
Filters={SelectTeamFilter}
Cards={LanguageCards}
Charts={LanguageCharts}
/>
</CardTab>
</TabbedCard>
</CopilotPage>
);
};

View File

@ -0,0 +1,140 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import { CopilotPage } from './CopilotPage';
interface InfoCardProps {
title: string;
description: string[];
buttonText: string;
onClick: () => void;
warning?: string | null;
}
const InfoCard = ({
title,
description,
buttonText,
onClick,
warning,
}: InfoCardProps) => (
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="div">
{title}
</Typography>
{description.map((text, index) => (
<Typography
key={index}
variant="body2"
color="text.secondary"
sx={{ mb: 2 }}
>
{text}
</Typography>
))}
</CardContent>
<Box sx={{ p: 2, pt: 0 }}>
{warning ? (
<Alert severity="warning" variant="outlined">
{warning}
</Alert>
) : (
<Button variant="contained" color="primary" fullWidth onClick={onClick}>
{buttonText}
</Button>
)}
</Box>
</Card>
);
export const HomePage = (): React.JSX.Element => {
const configApi = useApi(configApiRef);
const navigate = useNavigate();
const enterpriseConfig = configApi.getOptionalString('copilot.enterprise');
const organizationConfig = configApi.getOptionalString(
'copilot.organization',
);
const handleNavigate = (path: string) => {
navigate(path);
};
return (
<CopilotPage
title="Copilot Dashboard"
subtitle="Exploring the Impact and Integration of AI Assistance in Development Workflows"
themeId="tool"
>
<Container
maxWidth="lg"
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '70vh',
}}
>
<Grid container spacing={4} justifyContent="center" gap={2}>
<Grid item xs={12} sm={6} md={5}>
<InfoCard
title="Enterprise"
description={[
'Dive deep into enterprise-level metrics to track performance, user engagement, and more. Get an overview of how AI assistance is being adopted across the entire enterprise.',
'You can also explore metrics broken down by individual teams to gain more insights.',
]}
buttonText="Go to Enterprise"
onClick={() => handleNavigate('/copilot/enterprise')}
warning={
enterpriseConfig
? null
: "Please add the 'copilot.enterprise' variable in the configuration to enable this feature."
}
/>
</Grid>
<Grid item xs={12} sm={6} md={5}>
<InfoCard
title="Organization"
description={[
'Explore organization-wide statistics and gain insights into user activities and trends. Understand the broader impact of AI assistance within your organization.',
'Additionally, view metrics by teams to get a granular understanding of adoption patterns.',
]}
buttonText="Go to Organization"
onClick={() => handleNavigate('/copilot/organization')}
warning={
organizationConfig
? null
: "Please add the 'copilot.organization' variable in the configuration to enable this feature."
}
/>
</Grid>
</Grid>
</Container>
</CopilotPage>
);
};

View File

@ -0,0 +1,62 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { TabbedCard, CardTab } from '@backstage/core-components';
import { Metrics } from '../Metrics';
import { LanguageCards, DashboardCards } from '../Cards';
import { LanguageCharts, DashboardCharts } from '../Charts';
import { CopilotPage } from './CopilotPage';
import { SelectTeamFilter } from '../Filters';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
export const OrganizationPage = (): React.JSX.Element => {
const navigate = useNavigate();
const configApi = useApi(configApiRef);
useEffect(() => {
const organizationKey = configApi.getOptionalString('copilot.organization');
if (!organizationKey) {
navigate('/copilot');
}
}, [navigate, configApi]);
return (
<CopilotPage
title="Organization Statistics"
subtitle="Explore AI Usage and Activity Trends Within Your Organization"
themeId="tool"
>
<TabbedCard>
<CardTab label="Organization">
<Metrics
Filters={SelectTeamFilter}
Cards={DashboardCards}
Charts={DashboardCharts}
/>
</CardTab>
<CardTab label="Languages">
<Metrics
Filters={SelectTeamFilter}
Cards={LanguageCards}
Charts={LanguageCharts}
/>
</CardTab>
</TabbedCard>
</CopilotPage>
);
};

View File

@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CopilotPage } from './CopilotPage';
export { CopilotIndexPage } from './CopilotIndexPage';

View File

@ -0,0 +1,65 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
SidebarItem,
SidebarSubmenu,
SidebarSubmenuItem,
} from '@backstage/core-components';
import {
configApiRef,
IconComponent,
useApi,
} from '@backstage/core-plugin-api';
import {
SupportAgent as SupportAgentIcon,
Business as BusinessIcon,
Group as GroupIcon,
} from '@mui/icons-material';
export const Sidebar = (): React.JSX.Element => {
const configApi = useApi(configApiRef);
const enterpriseConfig = configApi.getOptionalString('copilot.enterprise');
const organizationConfig = configApi.getOptionalString(
'copilot.organization',
);
return (
<SidebarItem
icon={SupportAgentIcon as IconComponent}
to="copilot"
text="Copilot"
>
<SidebarSubmenu title="Copilot">
{enterpriseConfig && (
<SidebarSubmenuItem
title="Enterprise"
to="copilot/enterprise"
icon={BusinessIcon as IconComponent}
/>
)}
{organizationConfig && (
<SidebarSubmenuItem
title="Organization"
to="copilot/organization"
icon={GroupIcon as IconComponent}
/>
)}
</SidebarSubmenu>
</SidebarItem>
);
};

View File

@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
export { Sidebar } from './Sidebar';

View File

@ -1,248 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
type LanguageStats = {
language: string;
totalSuggestions: number;
totalAcceptances: number;
acceptanceRate: number;
};
function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
if (b[orderBy] < a[orderBy]) {
return -1;
}
if (b[orderBy] > a[orderBy]) {
return 1;
}
return 0;
}
type Order = 'asc' | 'desc';
function getComparator<Key extends keyof any>(
order: Order,
orderBy: Key,
): (
a: { [key in Key]: number | string },
b: { [key in Key]: number | string },
) => number {
return order === 'desc'
? (a, b) => descendingComparator(a, b, orderBy)
: (a, b) => -descendingComparator(a, b, orderBy);
}
function stableSort<T>(array: T[], comparator: (a: T, b: T) => number) {
const stabilizedThis = array.map((el, index) => [el, index] as [T, number]);
stabilizedThis.sort((a, b) => {
const order = comparator(a[0], b[0]);
if (order !== 0) return order;
return a[1] - b[1];
});
return stabilizedThis.map(el => el[0]);
}
interface HeadCell {
disablePadding: boolean;
id: keyof LanguageStats;
label: string;
numeric: boolean;
}
const headCells: HeadCell[] = [
{
id: 'language',
numeric: false,
disablePadding: true,
label: 'Language',
},
{
id: 'totalAcceptances',
numeric: true,
disablePadding: false,
label: 'Accepted Prompts',
},
{
id: 'totalSuggestions',
numeric: true,
disablePadding: false,
label: 'Total Suggestions',
},
{
id: 'acceptanceRate',
numeric: true,
disablePadding: false,
label: 'Acceptance Rate (%)',
},
];
interface EnhancedTableProps {
order: Order;
orderBy: string;
onRequestSort: (
event: React.MouseEvent<unknown>,
property: keyof LanguageStats,
) => void;
}
const EnhancedTableHead = (props: EnhancedTableProps) => {
const { order, orderBy, onRequestSort } = props;
const createSortHandler =
(property: keyof LanguageStats) => (event: React.MouseEvent<unknown>) => {
onRequestSort(event, property);
};
return (
<TableHead>
<TableRow>
{headCells.map(headCell => (
<TableCell
key={headCell.id}
align={headCell.numeric ? 'right' : 'left'}
padding={headCell.disablePadding ? 'none' : 'normal'}
sortDirection={orderBy === headCell.id ? order : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={createSortHandler(headCell.id)}
>
{headCell.label}
{orderBy === headCell.id ? (
<Typography
sx={{
border: 0,
clip: 'rect(0 0 0 0)',
height: 1,
margin: -1,
overflow: 'hidden',
padding: 0,
position: 'absolute',
top: 20,
width: 1,
}}
>
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
</Typography>
) : null}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
);
};
const StyledTableContainer = styled(TableContainer)(({ theme }) => ({
width: '100%',
marginBottom: theme.spacing(2),
}));
const StyledTable = styled(Table)(() => ({
minWidth: 750,
}));
export function LanguagesBreakdownTable({ rows }: { rows: LanguageStats[] }) {
const [order, setOrder] = React.useState<Order>('desc');
const [orderBy, setOrderBy] =
React.useState<keyof LanguageStats>('totalAcceptances');
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
const handleRequestSort = (
_event: React.MouseEvent<unknown>,
property: keyof LanguageStats,
) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const emptyRows =
rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage);
return (
<>
<StyledTableContainer>
<StyledTable
aria-labelledby="tableTitle"
size="medium"
aria-label="enhanced table"
>
<EnhancedTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleRequestSort}
/>
<TableBody>
{stableSort(rows, getComparator(order, orderBy))
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map(row => (
<TableRow hover tabIndex={-1} key={row.language}>
<TableCell component="th" scope="row" padding="none">
{row.language}
</TableCell>
<TableCell align="right">{row.totalAcceptances}</TableCell>
<TableCell align="right">{row.totalSuggestions}</TableCell>
<TableCell align="right">
{(row.acceptanceRate * 100).toFixed(2)}%
</TableCell>
</TableRow>
))}
{emptyRows > 0 && (
<TableRow style={{ height: 53 * emptyRows }}>
<TableCell colSpan={headCells.length} />
</TableRow>
)}
</TableBody>
</StyledTable>
</StyledTableContainer>
<TablePagination
rowsPerPageOptions={[10, 15, 25]}
component="div"
count={rows.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</>
);
}

View File

@ -0,0 +1,197 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
type LanguageStats = {
language: string;
totalSuggestions: number;
totalAcceptances: number;
acceptanceRate: number;
};
type Order = 'asc' | 'desc';
interface EnhancedTableProps {
title?: string;
rows: LanguageStats[];
}
const headCells = [
{ id: 'language', label: 'Language', numeric: false },
{ id: 'totalAcceptances', label: 'Accepted Prompts', numeric: true },
{ id: 'totalSuggestions', label: 'Accepted Lines of Code', numeric: true },
{ id: 'acceptanceRate', label: 'Acceptance Rate (%)', numeric: true },
];
const StyledTableContainer = styled(TableContainer)(() => ({
flex: 1,
}));
const StyledTableWrapper = styled(Box)(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const EnhancedTable = ({ title, rows }: EnhancedTableProps) => {
const [order, setOrder] = React.useState<Order>('desc');
const [orderBy, setOrderBy] =
React.useState<keyof LanguageStats>('totalAcceptances');
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
const handleRequestSort = (
_event: React.MouseEvent<unknown>,
property: keyof LanguageStats,
) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const sortedRows = [...rows].sort((a, b) => {
const aValue = a[orderBy];
const bValue = b[orderBy];
if (typeof aValue === 'string' && typeof bValue === 'string') {
return aValue.localeCompare(bValue) * (order === 'asc' ? 1 : -1);
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return (aValue - bValue) * (order === 'asc' ? 1 : -1);
}
return 0;
});
const paginatedRows = sortedRows.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
);
return (
<StyledTableWrapper>
{title && (
<Typography variant="h6" gutterBottom>
{title}
</Typography>
)}
<StyledTableContainer>
<Table
aria-labelledby="tableTitle"
size="medium"
aria-label="enhanced table"
>
<TableHead>
<TableRow>
{headCells.map(headCell => (
<TableCell
key={headCell.id}
align={headCell.numeric ? 'right' : 'left'}
sortDirection={orderBy === headCell.id ? order : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={event =>
handleRequestSort(
event,
headCell.id as keyof LanguageStats,
)
}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{paginatedRows.map(row => (
<TableRow hover tabIndex={-1} key={row.language}>
<TableCell component="th" scope="row">
{row.language}
</TableCell>
<TableCell align="right">{row.totalAcceptances}</TableCell>
<TableCell align="right">{row.totalSuggestions}</TableCell>
<TableCell align="right">
{(row.acceptanceRate * 100).toFixed(2)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</StyledTableContainer>
<TablePagination
rowsPerPageOptions={[10, 15, 25]}
component="div"
count={rows.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</StyledTableWrapper>
);
};
export function LanguagesComparisonTables({
team,
overallRows,
teamRows,
}: {
team?: string;
overallRows: LanguageStats[];
teamRows: LanguageStats[];
}) {
return (
<Box display="flex" flexDirection="row" gap={4} height="100%">
{team && (
<Box flex={1}>
<EnhancedTable title={team} rows={teamRows} />
</Box>
)}
<Box flex={1}>
<EnhancedTable
title={team ? 'Overall' : undefined}
rows={overallRows}
/>
</Box>
</Box>
);
}

View File

@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LanguagesBreakdownTable } from './LanguagesBreakdownTable';
export { LanguagesComparisonTables } from './LanguagesComparisonTables';

View File

@ -0,0 +1,16 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CopilotIndexPage } from './Pages';

View File

@ -0,0 +1,20 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import createStateContext from 'react-use/lib/factory/createStateContext';
export const [useSharedTeam, SharedTeamProvider] = createStateContext<
string | undefined
>(undefined);

View File

@ -17,3 +17,5 @@ export {
useSharedDateRange,
SharedDateRangeProvider,
} from './SharedDateRangeContext';
export { useSharedTeam, SharedTeamProvider } from './SharedTeamContext';

View File

@ -13,5 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useTeams } from './useTeams';
export { useMetrics } from './useMetrics';
export { useMetricsByTeam } from './useMetricsByTeam';
export { usePeriodRange } from './usePeriodRange';
export { useSetMetricsTypeFromRoute } from './useSetMetricsTypeFromRoute';

View File

@ -18,6 +18,7 @@ import useAsync from 'react-use/lib/useAsync';
import { useApi } from '@backstage/core-plugin-api';
import { Metric } from '@backstage-community/plugin-copilot-common';
import { copilotApiRef } from '../api';
import { useSetMetricsTypeFromRoute } from './useSetMetricsTypeFromRoute';
export function useMetrics(
startDate: Date,
@ -29,10 +30,14 @@ export function useMetrics(
} {
const api = useApi(copilotApiRef);
const { value, loading, error } = useAsync(
() => api.getMetrics(startDate, endDate),
[api, startDate, endDate],
);
const type = useSetMetricsTypeFromRoute();
const { value, loading, error } = useAsync(() => {
if (type) {
return api.getMetrics(startDate, endDate, type);
}
return Promise.resolve([]);
}, [api, type, startDate, endDate]);
return {
items: value,

View File

@ -0,0 +1,48 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import useAsync from 'react-use/lib/useAsync';
import { useApi } from '@backstage/core-plugin-api';
import { Metric } from '@backstage-community/plugin-copilot-common';
import { copilotApiRef } from '../api';
import { useSharedTeam } from '../contexts';
import { useSetMetricsTypeFromRoute } from './useSetMetricsTypeFromRoute';
export function useMetricsByTeam(
startDate: Date,
endDate: Date,
): {
items?: Metric[];
loading: boolean;
error?: Error;
} {
const api = useApi(copilotApiRef);
const [team] = useSharedTeam();
const type = useSetMetricsTypeFromRoute();
const { value, loading, error } = useAsync(() => {
if (type && team) {
return api.getMetrics(startDate, endDate, type, team);
}
return Promise.resolve([]);
}, [api, type, team, startDate, endDate]);
return {
items: value,
loading,
error,
};
}

View File

@ -18,6 +18,7 @@ import useAsync from 'react-use/lib/useAsync';
import { useApi } from '@backstage/core-plugin-api';
import { PeriodRange } from '@backstage-community/plugin-copilot-common';
import { copilotApiRef } from '../api';
import { useSetMetricsTypeFromRoute } from './useSetMetricsTypeFromRoute';
export function usePeriodRange(): {
item: PeriodRange | undefined;
@ -25,8 +26,14 @@ export function usePeriodRange(): {
error?: Error;
} {
const api = useApi(copilotApiRef);
const type = useSetMetricsTypeFromRoute();
const { value, loading, error } = useAsync(() => api.periodRange(), [api]);
const { value, loading, error } = useAsync(() => {
if (type) {
return api.periodRange(type);
}
return Promise.resolve(undefined);
}, [api, type]);
return {
item: value,

View File

@ -0,0 +1,32 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MetricsType } from '@backstage-community/plugin-copilot-common';
import { useEffect, useState } from 'react';
import { mappingRoutes } from '../utils';
export function useSetMetricsTypeFromRoute(): MetricsType | undefined {
const [type, setType] = useState<MetricsType>();
useEffect(() => {
if (!mappingRoutes[window.location.pathname])
throw Error('Mapping not implemented');
setType(mappingRoutes[window.location.pathname]);
}, []);
return type;
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/esm/useAsync';
import { copilotApiRef } from '../api';
import { useSetMetricsTypeFromRoute } from './useSetMetricsTypeFromRoute';
export function useTeams(startDate: Date, endDate: Date) {
const api = useApi(copilotApiRef);
const type = useSetMetricsTypeFromRoute();
const { value, loading, error } = useAsync(() => {
if (type) {
return api.fetchTeams(startDate, endDate, type);
}
return Promise.resolve([]);
}, [api, type, startDate, endDate]);
return {
items: value,
loading,
error,
};
}

View File

@ -20,4 +20,4 @@
* @packageDocumentation
*/
export { copilotPlugin, CopilotPage } from './plugin';
export { copilotPlugin, CopilotSidebar, CopilotIndexPage } from './plugin';

View File

@ -15,14 +15,18 @@
*/
import {
createApiFactory,
createComponentExtension,
createPlugin,
createRoutableExtension,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
import { CopilotClient, copilotApiRef } from './api';
import {
copilotRouteRef,
enterpriseRouteRef,
organizationRouteRef,
} from './routes';
/**
* The Copilot plugin for Backstage.
@ -43,19 +47,35 @@ export const copilotPlugin = createPlugin({
}),
],
routes: {
root: rootRouteRef,
copilot: copilotRouteRef,
enterprise: enterpriseRouteRef,
organization: organizationRouteRef,
},
});
/**
* Copilot page component for the Copilot plugin.
* CopilotIndexPage component for the Copilot plugin.
*
* @public
*/
export const CopilotPage = copilotPlugin.provide(
export const CopilotIndexPage = copilotPlugin.provide(
createRoutableExtension({
name: 'CopilotPage',
component: () => import('./components/Pages').then(m => m.CopilotPage),
mountPoint: rootRouteRef,
name: 'CopilotIndexPage',
component: () => import('./components/Pages').then(m => m.CopilotIndexPage),
mountPoint: copilotRouteRef,
}),
);
/**
* CopilotSidebar component for the Copilot plugin.
*
* @public
*/
export const CopilotSidebar = copilotPlugin.provide(
createComponentExtension({
name: 'OrganizationCopilotPage',
component: {
lazy: () => import('./components/Sidebar').then(m => m.Sidebar),
},
}),
);

View File

@ -13,8 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'copilot',
export const copilotRouteRef = createRouteRef({
id: 'copilot-dashboard',
});
export const enterpriseRouteRef = createSubRouteRef({
id: 'enterprise-dashboard',
parent: copilotRouteRef,
path: '/enterprise',
});
export const organizationRouteRef = createSubRouteRef({
id: 'organization-dashboard',
parent: copilotRouteRef,
path: '/organization',
});

View File

@ -15,6 +15,7 @@
*/
import { Metric } from '@backstage-community/plugin-copilot-common';
import React from 'react';
export type LanguageStats = {
language: string;
@ -24,11 +25,28 @@ export type LanguageStats = {
};
export type CardsProps = {
team?: string;
metrics: Metric[];
metricsByTeam: Metric[];
startDate: Date;
endDate: Date;
};
export type ChartsProps = {
team?: string;
metrics: Metric[];
metricsByTeam: Metric[];
};
export type FilterProps = {
team?: string;
setTeam: React.Dispatch<React.SetStateAction<string | undefined>>;
options: FilterOptions;
};
export type FilterOptions = FilterOption[];
export type FilterOption = {
label: string;
value: string;
};

View File

@ -14,7 +14,10 @@
* limitations under the License.
*/
import { Metric } from '@backstage-community/plugin-copilot-common';
import {
Metric,
MetricsType,
} from '@backstage-community/plugin-copilot-common';
import { LanguageStats } from '../types';
export function getTopLanguagesByAcceptedPrompts(
@ -56,3 +59,8 @@ export function getLanguageStats(metricsArray: Metric[]): LanguageStats[] {
});
return Array.from(languageStatsMap.values());
}
export const mappingRoutes: Record<string, MetricsType> = {
'/copilot/enterprise': 'enterprise',
'/copilot/organization': 'organization',
};

View File

@ -2773,6 +2773,7 @@ __metadata:
supertest: ^6.2.4
winston: ^3.2.1
yn: ^4.0.0
zod: ^3.23.8
languageName: unknown
linkType: soft
@ -2811,9 +2812,12 @@ __metadata:
luxon: ^3.5.0
msw: ^1.0.0
react-use: ^17.3.1
simple-date-range-calendar: ^1.0.10
simple-date-range-calendar: ^2.0.6
peerDependencies:
react: ^17.0.0 || ^18.0.0
react-dom: ^18.0.2
react-router: ^6.3.0
react-router-dom: ^6.3.0
languageName: unknown
linkType: soft
@ -6000,7 +6004,7 @@ __metadata:
languageName: node
linkType: hard
"@emotion/hash@npm:^0.9.1, @emotion/hash@npm:^0.9.2":
"@emotion/hash@npm:^0.9.2":
version: 0.9.2
resolution: "@emotion/hash@npm:0.9.2"
checksum: 379bde2830ccb0328c2617ec009642321c0e009a46aa383dfbe75b679c6aea977ca698c832d225a893901f29d7b3eef0e38cf341f560f6b2b56f1ff23c172387
@ -8253,7 +8257,7 @@ __metadata:
languageName: node
linkType: hard
"@mui/icons-material@npm:^5.16.4, @mui/icons-material@npm:^5.16.7":
"@mui/icons-material@npm:^5.16.7":
version: 5.16.7
resolution: "@mui/icons-material@npm:5.16.7"
dependencies:
@ -8269,7 +8273,7 @@ __metadata:
languageName: node
linkType: hard
"@mui/material@npm:^5.12.2, @mui/material@npm:^5.16.5, @mui/material@npm:^5.16.7":
"@mui/material@npm:^5.12.2, @mui/material@npm:^5.16.7":
version: 5.16.7
resolution: "@mui/material@npm:5.16.7"
dependencies:
@ -8302,7 +8306,7 @@ __metadata:
languageName: node
linkType: hard
"@mui/private-theming@npm:^5.16.5, @mui/private-theming@npm:^5.16.6":
"@mui/private-theming@npm:^5.16.6":
version: 5.16.6
resolution: "@mui/private-theming@npm:5.16.6"
dependencies:
@ -8340,38 +8344,7 @@ __metadata:
languageName: node
linkType: hard
"@mui/styles@npm:^5.16.4":
version: 5.16.5
resolution: "@mui/styles@npm:5.16.5"
dependencies:
"@babel/runtime": ^7.23.9
"@emotion/hash": ^0.9.1
"@mui/private-theming": ^5.16.5
"@mui/types": ^7.2.15
"@mui/utils": ^5.16.5
clsx: ^2.1.0
csstype: ^3.1.3
hoist-non-react-statics: ^3.3.2
jss: ^10.10.0
jss-plugin-camel-case: ^10.10.0
jss-plugin-default-unit: ^10.10.0
jss-plugin-global: ^10.10.0
jss-plugin-nested: ^10.10.0
jss-plugin-props-sort: ^10.10.0
jss-plugin-rule-value-function: ^10.10.0
jss-plugin-vendor-prefixer: ^10.10.0
prop-types: ^15.8.1
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 1ce9083949a19f1cd9765412f26cd5bfa919b0dafe3a851f57710204aa831714da4a84095bbff1da3785f5cf7686532be41726b798d0ab067a94d5349482e880
languageName: node
linkType: hard
"@mui/system@npm:^5.15.15, @mui/system@npm:^5.16.5, @mui/system@npm:^5.16.7":
"@mui/system@npm:^5.15.15, @mui/system@npm:^5.16.7":
version: 5.16.7
resolution: "@mui/system@npm:5.16.7"
dependencies:
@ -8411,7 +8384,7 @@ __metadata:
languageName: node
linkType: hard
"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.15.14, @mui/utils@npm:^5.16.5, @mui/utils@npm:^5.16.6":
"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.15.14, @mui/utils@npm:^5.16.6":
version: 5.16.6
resolution: "@mui/utils@npm:5.16.6"
dependencies:
@ -17663,10 +17636,10 @@ __metadata:
languageName: node
linkType: hard
"date-fns@npm:^3.6.0":
version: 3.6.0
resolution: "date-fns@npm:3.6.0"
checksum: 0daa1e9a436cf99f9f2ae9232b55e11f3dd46132bee10987164f3eebd29f245b2e066d7d7db40782627411ecf18551d8f4c9fcdf2226e48bb66545407d448ab7
"date-fns@npm:^4.1.0":
version: 4.1.0
resolution: "date-fns@npm:4.1.0"
checksum: fb681b242cccabed45494468f64282a7d375ea970e0adbcc5dcc92dcb7aba49b2081c2c9739d41bf71ce89ed68dd73bebfe06ca35129490704775d091895710b
languageName: node
linkType: hard
@ -23729,7 +23702,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-camel-case@npm:^10.10.0, jss-plugin-camel-case@npm:^10.5.1":
"jss-plugin-camel-case@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-camel-case@npm:10.10.0"
dependencies:
@ -23740,7 +23713,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-default-unit@npm:^10.10.0, jss-plugin-default-unit@npm:^10.5.1":
"jss-plugin-default-unit@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-default-unit@npm:10.10.0"
dependencies:
@ -23750,7 +23723,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-global@npm:^10.10.0, jss-plugin-global@npm:^10.5.1":
"jss-plugin-global@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-global@npm:10.10.0"
dependencies:
@ -23760,7 +23733,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-nested@npm:^10.10.0, jss-plugin-nested@npm:^10.5.1":
"jss-plugin-nested@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-nested@npm:10.10.0"
dependencies:
@ -23771,7 +23744,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-props-sort@npm:^10.10.0, jss-plugin-props-sort@npm:^10.5.1":
"jss-plugin-props-sort@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-props-sort@npm:10.10.0"
dependencies:
@ -23781,7 +23754,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-rule-value-function@npm:^10.10.0, jss-plugin-rule-value-function@npm:^10.5.1":
"jss-plugin-rule-value-function@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-rule-value-function@npm:10.10.0"
dependencies:
@ -23792,7 +23765,7 @@ __metadata:
languageName: node
linkType: hard
"jss-plugin-vendor-prefixer@npm:^10.10.0, jss-plugin-vendor-prefixer@npm:^10.5.1":
"jss-plugin-vendor-prefixer@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-vendor-prefixer@npm:10.10.0"
dependencies:
@ -23803,7 +23776,7 @@ __metadata:
languageName: node
linkType: hard
"jss@npm:10.10.0, jss@npm:^10.10.0, jss@npm:^10.5.1, jss@npm:~10.10.0":
"jss@npm:10.10.0, jss@npm:^10.5.1, jss@npm:~10.10.0":
version: 10.10.0
resolution: "jss@npm:10.10.0"
dependencies:
@ -30685,23 +30658,18 @@ __metadata:
languageName: node
linkType: hard
"simple-date-range-calendar@npm:^1.0.10":
version: 1.0.10
resolution: "simple-date-range-calendar@npm:1.0.10"
"simple-date-range-calendar@npm:^2.0.6":
version: 2.0.6
resolution: "simple-date-range-calendar@npm:2.0.6"
dependencies:
date-fns: ^4.1.0
peerDependencies:
"@emotion/react": ^11.13.0
"@emotion/styled": ^11.13.0
"@mui/icons-material": ^5.16.4
"@mui/material": ^5.16.5
"@mui/styles": ^5.16.4
"@mui/system": ^5.16.5
date-fns: ^3.6.0
typescript: ^5.5.3
web-vitals: ^2.1.4
peerDependencies:
react: ^18.3.1
react-dom: ^18.3.1
checksum: a7a77d47cd8be2e1658b0c30dc9a39ce5f62663b8356c4a96150e21b10d369f628391c7051e83e2821075ce944599a4729b67f4a0598d47e81135e686edc1e27
"@mui/material": ^5.16.7
react: ^18.0.0
react-dom: ^18.0.0
checksum: f24d91e32136183106313ec35d795551ffc95b8943bbe4fb2075ad9ebf8c4d29f41da58b5ee83a1f68323f75c0f7fb2ae0d22c6b68e34ee0bdb20593d53ebea4
languageName: node
linkType: hard
@ -32813,16 +32781,6 @@ __metadata:
languageName: node
linkType: hard
"typescript@npm:^5.5.3, typescript@npm:~5.5.0":
version: 5.5.4
resolution: "typescript@npm:5.5.4"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: b309040f3a1cd91c68a5a58af6b9fdd4e849b8c42d837b2c2e73f9a4f96a98c4f1ed398a9aab576ee0a4748f5690cf594e6b99dbe61de7839da748c41e6d6ca8
languageName: node
linkType: hard
"typescript@npm:~5.3.0":
version: 5.3.3
resolution: "typescript@npm:5.3.3"
@ -32833,6 +32791,16 @@ __metadata:
languageName: node
linkType: hard
"typescript@npm:~5.5.0":
version: 5.5.4
resolution: "typescript@npm:5.5.4"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: b309040f3a1cd91c68a5a58af6b9fdd4e849b8c42d837b2c2e73f9a4f96a98c4f1ed398a9aab576ee0a4748f5690cf594e6b99dbe61de7839da748c41e6d6ca8
languageName: node
linkType: hard
"typescript@patch:typescript@5.4.2#~builtin<compat/typescript>":
version: 5.4.2
resolution: "typescript@patch:typescript@npm%3A5.4.2#~builtin<compat/typescript>::version=5.4.2&hash=a1c5e5"
@ -32843,16 +32811,6 @@ __metadata:
languageName: node
linkType: hard
"typescript@patch:typescript@^5.5.3#~builtin<compat/typescript>, typescript@patch:typescript@~5.5.0#~builtin<compat/typescript>":
version: 5.5.4
resolution: "typescript@patch:typescript@npm%3A5.5.4#~builtin<compat/typescript>::version=5.5.4&hash=a1c5e5"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: fc52962f31a5bcb716d4213bef516885e4f01f30cea797a831205fc9ef12b405a40561c40eae3127ab85ba1548e7df49df2bcdee6b84a94bfbe3a0d7eff16b14
languageName: node
linkType: hard
"typescript@patch:typescript@~5.3.0#~builtin<compat/typescript>":
version: 5.3.3
resolution: "typescript@patch:typescript@npm%3A5.3.3#~builtin<compat/typescript>::version=5.3.3&hash=a1c5e5"
@ -32863,6 +32821,16 @@ __metadata:
languageName: node
linkType: hard
"typescript@patch:typescript@~5.5.0#~builtin<compat/typescript>":
version: 5.5.4
resolution: "typescript@patch:typescript@npm%3A5.5.4#~builtin<compat/typescript>::version=5.5.4&hash=a1c5e5"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: fc52962f31a5bcb716d4213bef516885e4f01f30cea797a831205fc9ef12b405a40561c40eae3127ab85ba1548e7df49df2bcdee6b84a94bfbe3a0d7eff16b14
languageName: node
linkType: hard
"uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5":
version: 1.0.6
resolution: "uc.micro@npm:1.0.6"
@ -33655,13 +33623,6 @@ __metadata:
languageName: node
linkType: hard
"web-vitals@npm:^2.1.4":
version: 2.1.4
resolution: "web-vitals@npm:2.1.4"
checksum: 03d3f47dbf55c3dce07beb0ff5de8ddd52e2d0a53a8df5c84e7a16dda93543341d67231fa79b1d9772b091419af4ec0fd395b8bcf451a0e26846e3f76b3d0efc
languageName: node
linkType: hard
"webidl-conversions@npm:^3.0.0":
version: 3.0.1
resolution: "webidl-conversions@npm:3.0.1"
@ -34543,7 +34504,7 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.22.4":
"zod@npm:^3.22.4, zod@npm:^3.23.8":
version: 3.23.8
resolution: "zod@npm:3.23.8"
checksum: 15949ff82118f59c893dacd9d3c766d02b6fa2e71cf474d5aa888570c469dbf5446ac5ad562bb035bf7ac9650da94f290655c194f4a6de3e766f43febd432c5c