Some initial code
Signed-off-by: Jonathan Mezach <jonathan.mezach@rr-wfm.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,11 @@
|
||||
# Octopus Deploy Plugin
|
||||
|
||||
Welcome to the octopus-deploy plugin!
|
||||
|
||||
## Features
|
||||
|
||||
- Display the deployment status of the most recent releases for a project in Octopus Deploy straight from the Backstage catalog
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { octopusDeployPlugin, OctopusDeployPage } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(octopusDeployPlugin)
|
||||
.addPage({
|
||||
element: <OctopusDeployPage />,
|
||||
title: 'Root Page',
|
||||
path: '/octopus-deploy'
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@backstage/plugin-octopus-deploy",
|
||||
"version": "0.1.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"clean": "backstage-cli package clean",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-components": "^0.12.3",
|
||||
"@backstage/core-plugin-api": "^1.3.0",
|
||||
"@backstage/theme": "^0.2.16",
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.22.1",
|
||||
"@backstage/core-app-api": "^1.4.0",
|
||||
"@backstage/dev-utils": "^1.0.11",
|
||||
"@backstage/test-utils": "^1.2.4",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "*",
|
||||
"msw": "^0.49.0",
|
||||
"cross-fetch": "^3.1.5"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createApiRef, DiscoveryApi, IdentityApi } from "@backstage/core-plugin-api";
|
||||
|
||||
export type OctopusProgression = {
|
||||
Environments: OctopusEnvironment[];
|
||||
Releases: OctopusReleaseProgression[];
|
||||
}
|
||||
|
||||
export type OctopusEnvironment = {
|
||||
Id: string,
|
||||
Name: string
|
||||
}
|
||||
|
||||
export type OctopusReleaseProgression = {
|
||||
Release: OctopusRelease
|
||||
Deployments: { [key: string]: OctopusDeployment[] }
|
||||
}
|
||||
|
||||
export type OctopusRelease = {
|
||||
Id: string
|
||||
Version: string
|
||||
}
|
||||
|
||||
export type OctopusDeployment = {
|
||||
State: string
|
||||
}
|
||||
|
||||
export const octopusDeployApiRef = createApiRef<OctopusDeployApi>({
|
||||
id: 'plugin.octopusdeploy.service'
|
||||
});
|
||||
|
||||
const DEFAULT_PROXY_PATH_BASE = '/octopus-deploy';
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi
|
||||
/**
|
||||
* Path to use for requests via the proxy, defaults to /octopus-deploy
|
||||
*/
|
||||
proxyPathBase?: string
|
||||
}
|
||||
|
||||
export interface OctopusDeployApi {
|
||||
getReleaseProgression(projectId: string, releaseHistoryCount: number): Promise<OctopusProgression>;
|
||||
}
|
||||
|
||||
export class OctopusDeployClient implements OctopusDeployApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly identityApi: IdentityApi;
|
||||
private readonly proxyPathBase: string;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.identityApi = options.identityApi;
|
||||
this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE;
|
||||
}
|
||||
|
||||
async getReleaseProgression(projectId: string, releaseHistoryCount: number): Promise<OctopusProgression> {
|
||||
const url = await this.getApiUrl(projectId, releaseHistoryCount);
|
||||
|
||||
const { token: idToken } = await this.identityApi.getCredentials();
|
||||
const response = await fetch(url, {
|
||||
headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
|
||||
});
|
||||
|
||||
let responseJson;
|
||||
|
||||
try {
|
||||
responseJson = await response.json();
|
||||
} catch (e) {
|
||||
responseJson = { releases: [] };
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Error communicating with Octopus Deploy: ${
|
||||
responseJson?.error?.title || response.statusText
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
private async getApiUrl(projectId: string, releaseHistoryCount: number) {
|
||||
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
|
||||
return `${proxyUrl}${this.proxyPathBase}/projects/${projectId}/progression?releaseHistoryCount=${releaseHistoryCount}`;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { useReleases } from '../../hooks/useReleases';
|
||||
import { getAnnotationFromEntity } from '../../utils/getAnnotationFromEntity';
|
||||
import React from 'react';
|
||||
import { ReleaseTable } from '../ReleaseTable';
|
||||
|
||||
export const EntityPageOctopusDeploy = (props: { defaultLimit?: number }) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
const projectId = getAnnotationFromEntity(entity);
|
||||
|
||||
const { environments, releases, loading, error } = useReleases(projectId, props.defaultLimit ?? 3);
|
||||
|
||||
return <ReleaseTable environments={environments} releases={releases} loading={loading} error={error} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EntityPageOctopusDeploy } from './EntityPageOctopusDeploy';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box, Typography } from "@material-ui/core";
|
||||
import {
|
||||
OctopusEnvironment,
|
||||
OctopusReleaseProgression
|
||||
} from '../../api';
|
||||
|
||||
import React from 'react';
|
||||
import { ResponseErrorPanel, StatusAborted, StatusError, StatusOK, StatusPending, StatusRunning, StatusWarning, Table, TableColumn } from "@backstage/core-components";
|
||||
|
||||
type ReleaseTableProps = {
|
||||
environments?: OctopusEnvironment[];
|
||||
releases?: OctopusReleaseProgression[];
|
||||
loading: boolean;
|
||||
error?: Error
|
||||
};
|
||||
|
||||
export const getDeploymentStatusComponent = (state: string | undefined) => {
|
||||
switch (state) {
|
||||
case "Success":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusOK /> Success
|
||||
</Typography>
|
||||
);
|
||||
case "Queued":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusPending /> Queued
|
||||
</Typography>
|
||||
);
|
||||
case "Executing":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusRunning /> Executing
|
||||
</Typography>
|
||||
);
|
||||
case "Failed":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusError /> Failed
|
||||
</Typography>
|
||||
);
|
||||
case "Cancelling":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusPending /> Cancelling
|
||||
</Typography>
|
||||
);
|
||||
case "Canceled":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusAborted /> Canceled
|
||||
</Typography>
|
||||
);
|
||||
case "TimedOut":
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusWarning /> Timed Out
|
||||
</Typography>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Typography component="span">
|
||||
<StatusWarning /> Unknown
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ReleaseTable = ({environments, releases, loading, error}: ReleaseTableProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<ResponseErrorPanel error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Version',
|
||||
field: 'Release.Version',
|
||||
highlight: false,
|
||||
width: 'auto'
|
||||
},
|
||||
...environments?.map(env => ({
|
||||
title: env.Name,
|
||||
width: 'auto',
|
||||
render: (row: Partial<OctopusReleaseProgression>) => {
|
||||
const deploymentsForEnvironment = row.Deployments[env.Id];
|
||||
if (deploymentsForEnvironment) {
|
||||
return (
|
||||
<Box display="flex" alignItems="center">
|
||||
<Typography variant="button">
|
||||
{getDeploymentStatusComponent(deploymentsForEnvironment[0].State)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
//field: `Deployments.${env.Id}[0].State` // TODO: We'll probably need to deal with multi-tenant deployments here
|
||||
})) ?? []
|
||||
]
|
||||
|
||||
return (
|
||||
<Table
|
||||
isLoading={loading}
|
||||
columns={columns}
|
||||
options={{
|
||||
search: true,
|
||||
paging: true,
|
||||
pageSize: 5,
|
||||
showEmptyDataSourceMessage: !loading
|
||||
}}
|
||||
title={
|
||||
<Box display="flex" alignItems="center">
|
||||
Octopus Deploy - Releases ({releases ? releases.length : 0})
|
||||
</Box>
|
||||
}
|
||||
data={releases ?? []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ReleaseTable } from './ReleaseTable';
|
||||
@@ -0,0 +1 @@
|
||||
export const OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION = 'octopus.com/project-id';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useApi } from "@backstage/core-plugin-api";
|
||||
import useAsync from "react-use/lib/useAsync";
|
||||
import { octopusDeployApiRef, OctopusEnvironment, OctopusReleaseProgression } from "../api";
|
||||
|
||||
export function useReleases(
|
||||
projectId: string,
|
||||
releaseHistoryCount: number
|
||||
): {
|
||||
environments?: OctopusEnvironment[],
|
||||
releases?: OctopusReleaseProgression[]
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
} {
|
||||
const api = useApi(octopusDeployApiRef);
|
||||
|
||||
const { value, loading, error } = useAsync(() => {
|
||||
return api.getReleaseProgression(projectId, releaseHistoryCount);
|
||||
}, [ api, projectId, releaseHistoryCount ]);
|
||||
|
||||
return {
|
||||
environments: value?.Environments,
|
||||
releases: value?.Releases,
|
||||
loading,
|
||||
error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
octopusDeployPlugin,
|
||||
EntityOctopusDeployContent,
|
||||
isOctopusDeployAvailable
|
||||
} from './plugin';
|
||||
|
||||
export * from './api';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { octopusDeployPlugin } from './plugin';
|
||||
|
||||
describe('octopus-deploy', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(octopusDeployPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION
|
||||
} from './constants';
|
||||
import {
|
||||
octopusDeployEntityContentRouteRef
|
||||
} from './routes';
|
||||
|
||||
import { OctopusDeployClient, octopusDeployApiRef } from './api';
|
||||
|
||||
import { createApiFactory, createPlugin, createRoutableExtension, discoveryApiRef, identityApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/** @public */
|
||||
export const isOctopusDeployAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION]);
|
||||
|
||||
export const octopusDeployPlugin = createPlugin({
|
||||
id: 'octopus-deploy',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: octopusDeployApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
|
||||
factory: ({ discoveryApi, identityApi }) => new OctopusDeployClient({ discoveryApi, identityApi })
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
/*
|
||||
export const OctopusDeployPage = octopusDeployPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'OctopusDeployPage',
|
||||
component: () =>
|
||||
import('./components/ExampleComponent').then(m => m.ExampleComponent),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
*/
|
||||
|
||||
export const EntityOctopusDeployContent = octopusDeployPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityOctopusDeployContent',
|
||||
component: () =>
|
||||
import('./components/EntityPageOctopusDeploy').then(
|
||||
m => m.EntityPageOctopusDeploy
|
||||
),
|
||||
mountPoint: octopusDeployEntityContentRouteRef
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const octopusDeployEntityContentRouteRef = createRouteRef({
|
||||
id: 'octopus-deploy-entity-content'
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION
|
||||
} from '../constants';
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export function getAnnotationFromEntity(entity: Entity): string {
|
||||
const annotation = entity.metadata.annotations?.[OCTOPUS_DEPLOY_PROJECT_ID_ANNOTATION];
|
||||
if (!annotation) {
|
||||
throw new Error('Value for annotation octopus.com/project-id was not found');
|
||||
}
|
||||
|
||||
return annotation;
|
||||
}
|
||||
Reference in New Issue
Block a user