diff --git a/plugins/xcmetrics/.eslintrc.js b/plugins/xcmetrics/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/xcmetrics/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/xcmetrics/README.md b/plugins/xcmetrics/README.md new file mode 100644 index 0000000000..a3bb93d2af --- /dev/null +++ b/plugins/xcmetrics/README.md @@ -0,0 +1,38 @@ +# XCMetrics + +[XCMetrics](https://xcmetrics.io) is a tool for collecting build metrics from XCode. +With this plugin, you can view data from XCMetrics directly in Backstage. + +![XCMetrics-overview](./docs/XCMetrics-overview.png) + +## Getting started + +From `packages/app`: + +```bash +yarn add @backstage/plugin-xcmetrics +``` + +In `packages/app/src/App.tsx`, add the following: + +```ts +import { XcmetricsPage } from '@backstage/plugin-xcmetrics'; +``` + +```tsx + + {/* Other routes... */} + } /> + +``` + +Add the URL to your XCMetrics backend instance in `app-config.yaml` like so: + +```yaml +proxy: + ... + '/xcmetrics': + target: http://127.0.0.1:8080/v1 +``` + +Start Backstage and navigate to `/xcmetrics` to view your build metrics! diff --git a/plugins/xcmetrics/api-report.md b/plugins/xcmetrics/api-report.md new file mode 100644 index 0000000000..3509fec523 --- /dev/null +++ b/plugins/xcmetrics/api-report.md @@ -0,0 +1,23 @@ +## API Report File for "@backstage/plugin-xcmetrics" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const XcmetricsPage: () => JSX.Element; + +// @public (undocumented) +export const xcmetricsPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/xcmetrics/dev/index.tsx b/plugins/xcmetrics/dev/index.tsx new file mode 100644 index 0000000000..f6663dd33d --- /dev/null +++ b/plugins/xcmetrics/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2021 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 { createDevApp } from '@backstage/dev-utils'; +import { xcmetricsPlugin, XcmetricsPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(xcmetricsPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/xcmetrics', + }) + .render(); diff --git a/plugins/xcmetrics/docs/XCMetrics-overview.png b/plugins/xcmetrics/docs/XCMetrics-overview.png new file mode 100644 index 0000000000..b0b7dc6153 Binary files /dev/null and b/plugins/xcmetrics/docs/XCMetrics-overview.png differ diff --git a/plugins/xcmetrics/package.json b/plugins/xcmetrics/package.json new file mode 100644 index 0000000000..e617d12e85 --- /dev/null +++ b/plugins/xcmetrics/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-xcmetrics", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core-components": "^0.1.5", + "@backstage/core-plugin-api": "^0.1.3", + "@backstage/errors": "^0.1.1", + "@backstage/theme": "^0.2.8", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "luxon": "^1.27.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.7.3", + "@backstage/core-app-api": "^0.1.4", + "@backstage/dev-utils": "^0.2.1", + "@backstage/test-utils": "^0.1.14", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/luxon": "^1.27.0", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.29.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/xcmetrics/src/api/XcmetricsClient.ts b/plugins/xcmetrics/src/api/XcmetricsClient.ts new file mode 100644 index 0000000000..00087ad75d --- /dev/null +++ b/plugins/xcmetrics/src/api/XcmetricsClient.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 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 { DiscoveryApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { BuildItem, BuildsResult, XcmetricsApi } from './types'; + +interface Options { + discoveryApi: DiscoveryApi; +} + +export class XcmetricsClient implements XcmetricsApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + } + + async getBuilds(): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/xcmetrics`; + const response = await fetch(`${baseUrl}/build`); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return ((await response.json()) as BuildsResult).items; + } +} diff --git a/plugins/xcmetrics/src/api/index.ts b/plugins/xcmetrics/src/api/index.ts new file mode 100644 index 0000000000..c184dc950d --- /dev/null +++ b/plugins/xcmetrics/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 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 * from './types'; +export * from './XcmetricsClient'; diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts new file mode 100644 index 0000000000..add59b1a86 --- /dev/null +++ b/plugins/xcmetrics/src/api/types.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2021 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 { createApiRef } from '@backstage/core-plugin-api'; + +export type BuildStatus = 'succeeded' | 'failed' | 'stopped'; + +export type BuildItem = { + userid: string; + warningCount: number; + duration: number; + startTimestamp: string; + isCi: boolean; + startTimestampMicroseconds: number; + category: string; + endTimestampMicroseconds: number; + day: string; + compilationEndTimestamp: string; + tag: string; + projectName: string; + compilationEndTimestampMicroseconds: number; + errorCount: number; + id: string; + buildStatus: BuildStatus; + compilationDuration: number; + schema: string; + compiledCount: number; + endTimestamp: string; + userid256: string; + machineName: string; + wasSuspended: boolean; +}; + +export type BuildsResult = { + items: BuildItem[]; + metadata: { + per: number; + total: number; + page: number; + }; +}; + +export interface XcmetricsApi { + getBuilds(): Promise; +} + +export const xcmetricsApiRef = createApiRef({ + id: 'plugin.xcmetrics.api', + description: 'Used by the XCMetrics plugin to make requests', +}); diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx new file mode 100644 index 0000000000..d386d279ce --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2021 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 { OverviewComponent } from './OverviewComponent'; +import { renderInTestApp } from '@backstage/test-utils'; +import { XcmetricsApi, xcmetricsApiRef } from '../../api'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; + +describe('OverviewComponent', () => { + it('should render', async () => { + const mockUserId = 'mockUser'; + const mockApi: jest.Mocked = { + getBuilds: jest.fn().mockResolvedValue([ + { + userid: mockUserId, + warningCount: 1, + duration: 123.45, + isCi: false, + projectName: 'App', + buildStatus: 'succeeded', + schema: 'AppSchema', + }, + ]), + }; + + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('XCMetrics Dashboard')).toBeInTheDocument(); + expect(rendered.getByText(mockUserId)).toBeInTheDocument(); + expect(rendered.queryByText('CI')).toBeNull(); + }); + + it('should render an empty state when no builds exist', async () => { + const mockApi: jest.Mocked = { + getBuilds: jest.fn().mockResolvedValue([]), + }; + + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('No builds to show')).toBeInTheDocument(); + }); + + it('should show an error when API not responding', async () => { + const errorMessage = 'MockErrorMessage'; + const mockApi: jest.Mocked = { + getBuilds: jest.fn().mockRejectedValue({ message: errorMessage }), + }; + + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText(errorMessage)).toBeInTheDocument(); + }); +}); diff --git a/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx new file mode 100644 index 0000000000..37b35c9690 --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewComponent/OverviewComponent.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2021 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 { + ContentHeader, + SupportButton, + Progress, + StatusOK, + StatusError, + StatusWarning, + Table, + TableColumn, + EmptyState, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { BuildItem, BuildStatus, xcmetricsApiRef } from '../../api'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { Duration } from 'luxon'; +import { Chip } from '@material-ui/core'; + +const formatStatus = (status: BuildStatus, warningCount: number) => { + const statusIcons = { + succeeded: , + failed: , + stopped: , + }; + + return ( + <> + {statusIcons[status]} {status[0].toUpperCase() + status.slice(1)} + {warningCount > 0 && ` with ${warningCount} warning`} + {warningCount > 1 && 's'} + + ); +}; + +const columns: TableColumn[] = [ + { + title: 'Project', + field: 'projectName', + }, + { + title: 'Schema', + field: 'schema', + }, + { + title: 'Duration', + field: 'duration', + type: 'time', + searchable: false, + render: data => Duration.fromObject({ seconds: data.duration }).toISOTime(), + }, + { + title: 'User', + field: 'userid', + }, + { + title: 'Status', + field: 'buildStatus', + render: data => formatStatus(data.buildStatus, data.warningCount), + }, + { + field: 'isCI', + render: data => data.isCi && , + width: '10', + sorting: false, + }, +]; + +export const OverviewComponent = () => { + const client = useApi(xcmetricsApiRef); + const { value: builds, loading, error } = useAsync( + async (): Promise => client.getBuilds(), + [], + ); + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + if (!builds || !builds.length) { + return ( + + ); + } + + return ( + <> + + Dashboard for XCMetrics + + + + ); +}; diff --git a/plugins/xcmetrics/src/components/OverviewComponent/index.ts b/plugins/xcmetrics/src/components/OverviewComponent/index.ts new file mode 100644 index 0000000000..58b284e6f1 --- /dev/null +++ b/plugins/xcmetrics/src/components/OverviewComponent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 * from './OverviewComponent'; diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx new file mode 100644 index 0000000000..96d841b083 --- /dev/null +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/XcmetricsLayout.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2021 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 { Content, Header, HeaderLabel, Page } from '@backstage/core-components'; +import { OverviewComponent } from '../OverviewComponent'; + +export const XcmetricsLayout = () => ( + +
+ + +
+ + + +
+); diff --git a/plugins/xcmetrics/src/components/XcmetricsLayout/index.ts b/plugins/xcmetrics/src/components/XcmetricsLayout/index.ts new file mode 100644 index 0000000000..eaaadcbaea --- /dev/null +++ b/plugins/xcmetrics/src/components/XcmetricsLayout/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 * from './XcmetricsLayout'; diff --git a/plugins/xcmetrics/src/index.ts b/plugins/xcmetrics/src/index.ts new file mode 100644 index 0000000000..aa02224738 --- /dev/null +++ b/plugins/xcmetrics/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 { xcmetricsPlugin, XcmetricsPage } from './plugin'; diff --git a/plugins/xcmetrics/src/plugin.test.ts b/plugins/xcmetrics/src/plugin.test.ts new file mode 100644 index 0000000000..055c647dd9 --- /dev/null +++ b/plugins/xcmetrics/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 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 { xcmetricsPlugin } from './plugin'; + +describe('xcmetrics', () => { + it('should export plugin', () => { + expect(xcmetricsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/xcmetrics/src/plugin.ts b/plugins/xcmetrics/src/plugin.ts new file mode 100644 index 0000000000..22250ff627 --- /dev/null +++ b/plugins/xcmetrics/src/plugin.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 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 { + createApiFactory, + createPlugin, + createRoutableExtension, + discoveryApiRef, +} from '@backstage/core-plugin-api'; +import { xcmetricsApiRef, XcmetricsClient } from './api'; +import { rootRouteRef } from './routes'; + +export const xcmetricsPlugin = createPlugin({ + id: 'xcmetrics', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: xcmetricsApiRef, + deps: { + discoveryApi: discoveryApiRef, + }, + factory({ discoveryApi }) { + return new XcmetricsClient({ discoveryApi }); + }, + }), + ], +}); + +export const XcmetricsPage = xcmetricsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/XcmetricsLayout').then(m => m.XcmetricsLayout), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/xcmetrics/src/routes.ts b/plugins/xcmetrics/src/routes.ts new file mode 100644 index 0000000000..7acd62c363 --- /dev/null +++ b/plugins/xcmetrics/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 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 { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + title: 'XCMetrics', +}); diff --git a/plugins/xcmetrics/src/setupTests.ts b/plugins/xcmetrics/src/setupTests.ts new file mode 100644 index 0000000000..fc6dbd98f8 --- /dev/null +++ b/plugins/xcmetrics/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 '@testing-library/jest-dom'; +import 'cross-fetch/polyfill';