Merge branch 'master' of github.com:spotify/backstage into blam/react-router

* 'master' of github.com:spotify/backstage: (45 commits)
  chore(catalog): simplify the filter types a little
  fix(catalog-backend): update the mock-data script to point to new example entities
  renamed example_components to example-components and deleted old exampled
  feat(catalog): add back ability for OR/IN type searches
  Add sample plugins to sidebar (#1243)
  chore(catalog): rename all pages and components to use Entity nomenclature
  fix(catalog): moar clean up
  Updated examples
  fix(catalog): add types and clean up code
  Added owner and lifecycle to catalog table, slightly updated examples
  chore(catalog): the component type is gone
  yarn.lock again...
  fix(catalog): merge errors
  review fixes. i thought about another force update for a moment :D
  move components to separate files
  fix(catalog): moar clean up
  fix(catalog): make code intention clear by renaming
  Merge pull request #1214 from spotify/feat/star-components
  Merge pull request #1194 from spotify/freben/backend-common-service
  Merge pull request #1215 from spotify/mob/sidebar-auth
  ...
This commit is contained in:
blam
2020-06-11 17:36:37 +02:00
92 changed files with 2249 additions and 534 deletions
+5
View File
@@ -7,6 +7,11 @@ This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing catalog related services.
## Getting Started
After starting the backend, you can issue the `yarn mock-catalog-data` command
in this directory to populate the catalog with some mock entities.
## Links
- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog]
@@ -0,0 +1,60 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: podcast-api
description: Podcast API
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-lookup
description: Artist Lookup
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: searcher
description: Searcher
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playback-order
description: Playback Order
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: shuffle-api
description: Shuffle API
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: queue-proxy
description: Queue Proxy
spec:
type: website
lifecycle: production
owner: tools@example.com
@@ -1,6 +0,0 @@
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: component3
spec:
type: service
@@ -1,14 +0,0 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playlist-proxy
spec:
type: service
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-web
spec:
type: website
+1 -1
View File
@@ -13,7 +13,7 @@
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean",
"mock-data": "./scripts/mock-data"
"mock-catalog-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
+1 -1
View File
@@ -5,5 +5,5 @@ curl \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "github",
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml"
}'
@@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => {
updateEntity: jest.fn(),
entities: jest.fn(),
entity: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
@@ -15,6 +15,9 @@
*/
import type { Entity } from '@backstage/catalog-model';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/backend-common';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -78,7 +81,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
async removeEntityByUid(uid: string): Promise<void> {
return await this.database.transaction(async tx => {
await this.database.removeEntity(tx, uid);
const entityResponse = await this.database.entityByUid(tx, uid);
if (!entityResponse) {
throw new NotFoundError(`Entity with ID ${uid} was not found`);
}
const location =
entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION];
const colocatedEntities = location
? await this.database.entities(tx, [
{
key: LOCATION_ANNOTATION,
values: [location],
},
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
}
if (entityResponse.locationId) {
await this.database.removeLocation(tx, entityResponse?.locationId!);
}
return undefined;
});
}
@@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
}
async removeLocation(id: string): Promise<void> {
await this.database.removeLocation(id);
await this.database.transaction(tx => this.database.removeLocation(tx, id));
}
async locations(): Promise<LocationResponse[]> {
@@ -105,8 +105,7 @@ describe('CommonDatabase', () => {
expect(locations).toEqual([output]);
const location = await db.location(locations[0].id);
expect(location).toEqual(output);
await db.removeLocation(locations[0].id);
await db.transaction(tx => db.removeLocation(tx, locations[0].id));
await expect(db.locations()).resolves.toEqual([]);
await expect(db.location(locations[0].id)).rejects.toThrow(
@@ -319,6 +319,21 @@ export class CommonDatabase implements Database {
return toEntityResponse(rows[0]);
}
async entityByUid(
txOpaque: unknown,
id: string,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
if (rows.length !== 1) {
return undefined;
}
return toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
@@ -341,10 +356,10 @@ export class CommonDatabase implements Database {
});
}
async removeLocation(id: string): Promise<void> {
const result = await this.database<DbLocationsRow>('locations')
.where({ id })
.del();
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
throw new NotFoundError(`Found no location with ID ${id}`);
@@ -130,11 +130,13 @@ export type Database = {
namespace?: string,
): Promise<DbEntityResponse | undefined>;
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(id: string): Promise<void>;
removeLocation(tx: unknown, id: string): Promise<void>;
location(id: string): Promise<DbLocationsRowWithStatus>;
@@ -21,8 +21,12 @@ describe('CatalogClient', () => {
it('builds entity search filters properly', async () => {
mockFetch.mockResponse('[]');
const client = new CatalogClient({ apiOrigin: '', basePath: '' });
const entities = await client.getEntities({ a: '1', ö: '=' });
const entities = await client.getEntities({
a: '1',
b: ['2', '3'],
ö: '=',
});
expect(entities).toEqual([]);
expect(mockFetch).toBeCalledWith('/entities?a=1&%C3%B6=%3D');
expect(mockFetch).toBeCalledWith('/entities?a=1&b=2&b=3&%C3%B6=%3D');
});
});
+30 -5
View File
@@ -20,7 +20,6 @@ import {
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
import { DescriptorEnvelope } from '../types';
import { CatalogApi, EntityCompoundName } from './types';
export class CatalogClient implements CatalogApi {
@@ -78,16 +77,26 @@ export class CatalogClient implements CatalogApi {
}
async getEntities(
filter?: Record<string, string>,
): Promise<DescriptorEnvelope[]> {
const cachedValue = this.cache.get<DescriptorEnvelope[]>(
filter?: Record<string, string | string[]>,
): Promise<Entity[]> {
const cachedValue = this.cache.get<Entity[]>(
`get:${JSON.stringify(filter)}`,
);
if (cachedValue) return cachedValue;
let path = `/entities`;
if (filter) {
path += `?${new URLSearchParams(filter).toString()}`;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (Array.isArray(value)) {
for (const v of value) {
params.append(key, v);
}
} else {
params.append(key, value);
}
}
path += `?${params.toString()}`;
}
return await this.getRequired(path);
@@ -134,4 +143,20 @@ export class CatalogClient implements CatalogApi {
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
async removeEntityByUid(uid: string): Promise<void> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
return undefined;
}
}
+3 -1
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { Entity, Location } from '@backstage/catalog-model';
@@ -33,9 +34,10 @@ export interface CatalogApi {
getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
getEntities(filter?: Record<string, string | string[]>): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../../api/types';
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
describe('Catalog Filter', () => {
it('should render the different groups', async () => {
@@ -41,11 +41,11 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
},
],
@@ -68,12 +68,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -97,12 +97,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -136,12 +136,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Card,
@@ -25,9 +26,9 @@ import {
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
export type CatalogFilterItem = {
id: FilterGroupItem;
id: EntityFilterType;
label: string;
icon?: IconComponent;
count?: number | React.FC;
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useStarredEntities } from '../../hooks/useStarredEntites';
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
@@ -28,6 +27,7 @@ import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { CatalogPage } from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
@@ -37,7 +37,7 @@ import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { dataResolvers, defaultFilter, filterGroups } from '../../data/filters';
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
@@ -70,10 +70,11 @@ export const CatalogPage: FC<{}> = () => {
defaultFilter,
);
const { value, error, loading } = useAsync(
() => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }),
[selectedFilter.id, starredEntities.size],
);
const { value, error, loading } = useAsync(async () => {
const filter = entityFilters[selectedFilter.id];
const all = await catalogApi.getEntities();
return all.filter(e => filter(e, { isStarred: isStarredEntity(e) }));
}, [selectedFilter.id, starredEntities.size]);
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
@@ -182,7 +183,7 @@ export const CatalogPage: FC<{}> = () => {
>
Create Service
</Button>
<SupportButton>All your components</SupportButton>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<div>
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
@@ -50,12 +51,12 @@ describe('CatalogTable component', () => {
),
);
const errorMessage = await rendered.findByText(
/Error encountered while fetching components./,
/Error encountered while fetching catalog entities./,
);
expect(errorMessage).toBeInTheDocument();
});
it('should display component names when loading has finished and no error occurred', async () => {
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInTestApp(
<CatalogTable
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
@@ -44,8 +45,12 @@ const columns: TableColumn[] = [
),
},
{
title: 'Kind',
field: 'kind',
title: 'Owner',
field: 'spec.owner',
},
{
title: 'Lifecycle',
field: 'spec.lifecycle',
},
{
title: 'Description',
@@ -72,7 +77,7 @@ export const CatalogTable: FC<CatalogTableProps> = ({
return (
<div>
<Alert severity="error">
Error encountered while fetching components. {error.toString()}
Error encountered while fetching catalog entities. {error.toString()}
</Alert>
</div>
);
@@ -13,21 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentContextMenu } from './ComponentContextMenu';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import * as React from 'react';
import { act } from 'react-dom/test-utils';
import { EntityContextMenu } from './EntityContextMenu';
describe('ComponentContextMenu', () => {
it('should call onUnregisterComponent on button click', async () => {
it('should call onUnregisterEntity on button click', async () => {
await act(async () => {
const mockCallback = jest.fn();
const menu = render(
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
<EntityContextMenu onUnregisterEntity={mockCallback} />,
);
const button = await menu.findByTestId('menu-button');
button.click();
const unregister = await menu.findByText('Unregister component');
fireEvent.click(button);
const unregister = await menu.findByText('Unregister entity');
expect(unregister).toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IconButton,
ListItemIcon,
@@ -34,13 +35,11 @@ const useStyles = makeStyles({
},
});
type ComponentContextMenuProps = {
onUnregisterComponent: () => void;
type Props = {
onUnregisterEntity: () => void;
};
export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
export const EntityContextMenu: FC<Props> = ({ onUnregisterEntity }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const classes = useStyles();
@@ -53,7 +52,7 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
};
return (
<div>
<>
<IconButton
aria-label="more"
aria-controls="long-menu"
@@ -75,13 +74,13 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
<MenuItem
onClick={() => {
onClose();
onUnregisterComponent();
onUnregisterEntity();
}}
>
<ListItemIcon>
<Cancel fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Unregister component</Typography>
<Typography variant="inherit">Unregister entity</Typography>
</MenuItem>
<MenuItem>
<ListItemIcon>
@@ -91,6 +90,6 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
</MenuItem>
</MenuList>
</Popover>
</div>
</>
);
};
@@ -13,21 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { ComponentMetadataCard } from './ComponentMetadataCard';
import { EntityMetadataCard } from './EntityMetadataCard';
describe('ComponentMetadataCard component', () => {
it('should display component name if provided', async () => {
describe('EntityMetadataCard component', () => {
it('should display entity name if provided', async () => {
const testEntity: Entity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: { name: 'test' },
};
const rendered = await render(
<ComponentMetadataCard entity={testEntity} />,
);
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
expect(await rendered.findByText('test')).toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
@@ -21,7 +22,7 @@ type Props = {
entity: Entity;
};
export const ComponentMetadataCard: FC<Props> = ({ entity }) => (
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
@@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentPage } from './ComponentPage';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, wait } from '@testing-library/react';
import * as React from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { catalogApiRef, CatalogApi } from '../../api/types';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage } from './EntityPage';
const getTestProps = (name: string) => {
return {
@@ -36,8 +37,8 @@ const getTestProps = (name: string) => {
const errorApi = { post: () => {} };
describe('ComponentPage', () => {
it('should redirect to component table page when name is not provided', async () => {
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
const props = getTestProps('');
render(
wrapInTestApp(
@@ -52,7 +53,7 @@ describe('ComponentPage', () => {
],
])}
>
<ComponentPage {...props} />
<EntityPage {...props} />
</ApiProvider>,
),
);
@@ -31,13 +31,13 @@ import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu';
import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard';
import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
const REDIRECT_DELAY = 1000;
type ComponentPageProps = {
type Props = {
match: {
params: {
optionalNamespaceAndName: string;
@@ -68,7 +68,7 @@ function headerProps(
};
}
export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
export const EntityPage: FC<Props> = ({ match, history }) => {
const { optionalNamespaceAndName, kind } = match.params;
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
@@ -83,7 +83,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Component not found!'));
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
history.push('/');
}, REDIRECT_DELAY);
@@ -95,14 +95,12 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
return null;
}
const removeComponent = async () => {
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
// await componentFactory.removeComponentByName(componentName);
history.push('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
// TODO - Replace with proper tabs implementation
const tabs = [
@@ -143,9 +141,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
// TODO: Switch theme and type props based on component type (website, library, ...)
<Page theme={pageTheme.service}>
<Header title={headerTitle} type={headerType}>
{entity && (
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
)}
{entity && <EntityContextMenu onUnregisterEntity={showRemovalDialog} />}
</Header>
{loading && <Progress />}
@@ -163,7 +159,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard entity={entity} />
<EntityMetadataCard entity={entity} />
</Grid>
<Grid item>
<SentryIssuesWidget
@@ -174,11 +170,11 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
</Grid>
</Content>
<ComponentRemovalDialog
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity}
onClose={hideRemovalDialog}
onConfirm={removeComponent}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
@@ -15,7 +15,7 @@
*/
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { Progress, useApi } from '@backstage/core';
import { Progress, useApi, alertApiRef } from '@backstage/core';
import {
Button,
Dialog,
@@ -33,7 +33,7 @@ import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api/types';
type ComponentRemovalDialogProps = {
type Props = {
open: boolean;
onConfirm: () => any;
onClose: () => any;
@@ -50,7 +50,7 @@ function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
}, [catalogApi, entity]);
}
export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
export const UnregisterEntityDialog: FC<Props> = ({
open,
onConfirm,
onClose,
@@ -59,11 +59,24 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
const { value: entities, loading, error } = useColocatedEntities(entity);
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const removeEntity = async () => {
const uid = entity.metadata.uid;
try {
await catalogApi.removeEntityByUid(uid!);
} catch (err) {
alertApi.post({ message: err.message });
}
onConfirm();
};
return (
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
Are you sure you want to unregister this entity?
</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
@@ -90,21 +103,23 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
<Typography component="div">
<ul>
<li>
{entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]}
{entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the component in Backstage.
To undo, just re-register the entity in Backstage.
</DialogContentText>
</>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onClose} color="primary">
Cancel
</Button>
<Button
disabled={!!(loading || error)}
onClick={onConfirm}
onClick={removeEntity}
color="secondary"
>
Unregister
+22 -25
View File
@@ -13,30 +13,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { StarredCount } from '../components/CatalogFilter/StarredCount';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import { FilterGroupItem } from '../types';
import { CatalogApi } from '../..';
import { Entity } from '@backstage/catalog-model';
export enum EntityFilterType {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: FilterGroupItem.OWNED,
id: EntityFilterType.OWNED,
label: 'Owned',
count: 0,
icon: SettingsIcon,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Starred',
count: StarredCount,
icon: StarIcon,
@@ -48,7 +53,7 @@ export const filterGroups: CatalogFilterGroup[] = [
name: 'Company',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'All Services',
count: AllServicesCount,
},
@@ -56,24 +61,16 @@ export const filterGroups: CatalogFilterGroup[] = [
},
];
type ResolverFunction = ({
catalogApi,
isStarredEntity,
}: {
catalogApi: CatalogApi;
isStarredEntity: (entity: Entity) => boolean;
}) => Promise<Entity[]>;
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
[FilterGroupItem.OWNED]: async () => [],
[FilterGroupItem.ALL]: async ({ catalogApi }) => {
return catalogApi.getEntities();
},
[FilterGroupItem.STARRED]: async ({ catalogApi, isStarredEntity }) => {
const allEntities = await catalogApi.getEntities();
type EntityFilterOptions = {
isStarred: boolean;
};
return allEntities.filter(entity => isStarredEntity(entity));
},
export const entityFilters: Record<string, EntityFilter> = {
[EntityFilterType.OWNED]: () => false,
[EntityFilterType.ALL]: () => true,
[EntityFilterType.STARRED]: (_, { isStarred }) => isStarred,
};
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
+2 -13
View File
@@ -13,23 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
EntityMeta,
LocationSpec,
LOCATION_ANNOTATION,
EntityMeta,
} from '@backstage/catalog-model';
import { Component } from './component';
export function entityToComponent(envelope: Entity): Component {
return {
name: envelope.metadata.name,
namespace: envelope.metadata.namespace,
kind: envelope.kind,
metadata: envelope.metadata,
description: envelope.metadata.annotations?.description ?? 'placeholder',
};
}
export function findLocationForEntityMeta(
meta: EntityMeta,
@@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApi, storageApiRef } from '@backstage/core';
import { useObservable } from 'react-use';
import { Entity } from '@backstage/catalog-model';
import { storageApiRef, useApi } from '@backstage/core';
import { useCallback, useEffect, useState } from 'react';
import { useObservable } from 'react-use';
const buildEntityKey = (component: Entity) =>
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { useStarredEntities } from './useStarredEntites';
-1
View File
@@ -17,5 +17,4 @@
export { plugin } from './plugin';
export * from './api/CatalogClient';
export * from './api/types';
export * from './types';
export * from './routes';
+2 -2
View File
@@ -16,13 +16,13 @@
import { createPlugin } from '@backstage/core';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { ComponentPage } from './components/ComponentPage/ComponentPage';
import { EntityPage } from './components/EntityPage/EntityPage';
import { entityRoute, rootRoute } from './routes';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.addRoute(rootRoute, CatalogPage);
router.addRoute(entityRoute, ComponentPage);
router.addRoute(entityRoute, EntityPage);
},
});
-165
View File
@@ -1,165 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
spec: {
type: string;
};
}
export type ComponentDescriptor = ComponentDescriptorV1beta1;
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace, for any given kind.
*/
name: string;
/**
* The short description of the entity.
*
* A a human readable string.
*/
description: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
/**
* The format envelope that's common to all versions/kinds.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type DescriptorEnvelope = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Optional metadata related to the entity.
*/
metadata: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Parses and validates descriptors.
*
* The output must be validated and well formed.
*/
export type DescriptorParser = {
/**
* Parses and validates a single raw descriptor.
*
* @param descriptor A raw descriptor object
* @returns A structure describing the parsed and validated descriptor
* @throws An Error if the descriptor was malformed
*/
parse(descriptor: object): Promise<DescriptorEnvelope>;
};
/**
* Parses and validates a single envelope into its materialized kind.
*
* These parsers may assume that the envelope is already validated and well
* formed.
*/
export type KindParser = {
/**
* Try to parse an envelope into a materialized kind.
*
* @param envelope A valid descriptor envelope
* @returns A materialized type, or undefined if the given version/kind is
* not meant to be handled by this parser
* @throws An Error if the type was handled and found to not be properly
* formatted
*/
tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
export enum FilterGroupItem {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
@@ -15,20 +15,12 @@
*/
import React from 'react';
import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core';
import { Box } from '@material-ui/core';
export const Layout: React.FC = ({ children }) => {
return (
<Page theme={pageTheme.tool}>
<Header
pageTitleOverride="Circle CI"
title={
<Box display="flex" alignItems="center">
<Box mr={1} /> Circle CI
</Box>
}
>
<HeaderLabel label="Owner" value="Team X" />
<Header title="CircleCI" subtitle="See recent builds and their status">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
{children}
@@ -80,6 +80,14 @@ const toolsCards = [
'https://camo.githubusercontent.com/517398c3fbe0687d3d4dcbe05da82970b882e75a/68747470733a2f2f64337676366c703535716a6171632e636c6f756466726f6e742e6e65742f6974656d732f33413061324e314c3346324f304c3377326e316a2f477261706869514c382e706e673f582d436c6f75644170702d56697369746f722d49643d3433363432',
tags: ['graphql', 'dev'],
},
{
title: 'GitOps Clusters',
description:
'Create GitOps-managed clusters with Backstage. Currently supports EKS flavors and profiles like Machine Learning Ops (MLOps)',
url: '/gitops-clusters',
image: 'https://miro.medium.com/max/801/1*R28u8gj-hVdDFISoYqPhrQ.png',
tags: ['gitops', 'dev'],
},
];
const ExplorePluginPage: FC<{}> = () => {
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+26
View File
@@ -0,0 +1,26 @@
# gitops-profiles
Welcome to the gitops-profiles plugin!
This plugin is for creating GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions.
_This plugin was created through the Backstage CLI_
## Plugin Development
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gitops-clusters](http://localhost:3000/gitops-profiles).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
## Use GitOps-API backend with Backstage
The backend of this plugin is written in Golang and its source code is available [here](https://github.com/chanwit/gitops-api) as a separate GitHub repository.
The binary of this plugin is available as a ready-to-use Docker image, [https://hub.docker.com/chanwit/gitops-api](https://hub.docker.com/chanwit/gitops-api).
To start using GitOps with Backstage, you have to start the backend using the following command:
```bash
$ docker run -d --init -p 3008:8080 chanwit/gitops-api
```
Please note that this plugin requires the backend to run on port 3008.
@@ -13,13 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityMeta } from '@backstage/catalog-model';
import { ReactNode } from 'react';
export type Component = {
name: string;
namespace?: string;
kind: string;
metadata: EntityMeta;
description: ReactNode;
};
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.7",
"main": "dist/index.esm.js",
"main:src": "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"
},
"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": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright 2020 Spotify AB
*
* 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-api';
export interface CloneFromTemplateRequest {
templateRepository: string;
secrets: {
awsAccessKeyId: string;
awsSecretAccessKey: string;
};
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface ApplyProfileRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
profiles: string[];
}
export interface ChangeClusterStateRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
clusterState: 'present' | 'absent'; // /api/cluster/state
}
export interface PollLogRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface Status {
status: string; // queued, in_progress, or completed
message: string;
conclusion: string; // success, failure, neutral, cancelled, skipped, timed_out, or action_required
}
export interface StatusResponse {
result: Status[];
link: string;
status: string;
}
export interface ClusterStatus {
name: string;
link: string;
status: string;
conclusion: string;
runStatus: Status[];
}
export interface ListClusterStatusesResponse {
result: ClusterStatus[];
}
export interface ListClusterRequest {
gitHubUser: string;
gitHubToken: string;
}
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
}
static async forResponse(resp: Response): Promise<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
export type GitOpsApi = {
url: string;
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
id: 'plugin.gitops.service',
description: 'Used by the GitOps profiles plugin to make requests',
});
export class GitOpsRestApi implements GitOpsApi {
constructor(public url: string = '') {}
private async fetch<T = any>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${this.url}${path}`, init);
if (!resp.ok) throw await FetchError.forResponse(resp);
return await resp.json();
}
async fetchLog(req: PollLogRequest): Promise<StatusResponse> {
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async changeClusterState(req: ChangeClusterStateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/state', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/clone-from-template', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async applyProfiles(req: ApplyProfileRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/profiles', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async listClusters(
req: ListClusterRequest,
): Promise<ListClusterStatusesResponse> {
return await this.fetch<ListClusterStatusesResponse>('/api/clusters', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
}
@@ -0,0 +1,95 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC } from 'react';
import {
Content,
ContentHeader,
Header,
SupportButton,
Page,
pageTheme,
Progress,
HeaderLabel,
useApi,
} from '@backstage/core';
import ClusterTable from '../ClusterTable/ClusterTable';
import { Button, Typography } from '@material-ui/core';
import { useAsync, useLocalStorage } from 'react-use';
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
const ClusterList: FC<{}> = () => {
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const api = useApi(gitOpsApiRef);
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
() => {
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
});
},
);
let content: JSX.Element;
if (loading) {
content = (
<Content>
<Progress />
</Content>
);
} else if (error) {
content = (
<Content>
<Typography variant="h4" color="error">
Failed to load cluster, {String(error)}
</Typography>
</Content>
);
} else {
content = (
<Content>
<ContentHeader title="Clusters">
<Button
variant="contained"
color="primary"
href="/gitops-cluster-create"
>
Create GitOps-managed Cluster
</Button>
<SupportButton>All clusters</SupportButton>
</ContentHeader>
<ClusterTable components={value!.result} />
</Content>
);
}
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
{content}
</Page>
);
};
export default ClusterList;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default } from './ClusterList';
@@ -0,0 +1,103 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC, useEffect, useState } from 'react';
import {
Content,
Header,
Page,
pageTheme,
Table,
Progress,
HeaderLabel,
useApi,
} from '@backstage/core';
import { Link } from '@material-ui/core';
import { useParams } from 'react-router-dom';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
import { transformRunStatus } from '../ProfileCatalog';
const ClusterPage: FC<{}> = () => {
const params = useParams<{ owner: string; repo: string }>();
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const [pollingLog, setPollingLog] = useState(true);
const [runStatus, setRunStatus] = useState<Status[]>([]);
const [runLink, setRunLink] = useState<string>('');
const [showProgress, setShowProgress] = useState(true);
const api = useApi(gitOpsApiRef);
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'message', title: 'Message' },
];
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: params.owner,
targetRepo: params.repo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
return () => {};
}, [pollingLog, api, loginInfo, params]);
return (
<Page theme={pageTheme.home}>
<Header title={`Cluster ${params.owner}/${params.repo}`}>
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
<Content>
<Progress hidden={!showProgress} />
<Table
options={{ search: false, paging: false, toolbar: false }}
data={transformRunStatus(runStatus)}
columns={columns}
/>
<Link
hidden={runLink === ''}
rel="noopener noreferrer"
href={`${runLink}?check_suite_focus=true`}
target="_blank"
>
Details
</Link>
</Content>
</Page>
);
};
export default ClusterPage;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default } from './ClusterPage';
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC } from 'react';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { ClusterStatus } from '../../api';
import { transformStatus } from '../ProfileCatalog/ProfileCatalog';
const columns: TableColumn[] = [
{
title: 'Cluster Name',
field: 'name',
highlight: true,
render: (componentData: any) => (
<Link href={`/gitops-cluster/${componentData.name}`}>
{componentData.name}
</Link>
),
},
{
title: 'Status',
field: 'status',
render: (componentData: any) => (
<>
{transformStatus({
status: componentData.status,
conclusion: componentData.conclusion,
message: componentData.status,
})}
</>
),
},
{
title: 'Conclusion',
field: 'Conclusion',
render: (componentData: any) => (
<>
{transformStatus({
status: componentData.status,
conclusion: componentData.conclusion,
message: componentData.conclusion,
})}
</>
),
},
];
type ClusterTableProps = {
components: ClusterStatus[];
};
const ClusterTable: FC<ClusterTableProps> = ({ components }) => {
return (
<Table columns={columns} options={{ paging: false }} data={components} />
);
};
export default ClusterTable;
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC } from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import CardActions from '@material-ui/core/CardActions';
import Avatar from '@material-ui/core/Avatar';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import { red } from '@material-ui/core/colors';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxWidth: 345,
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
expand: {
transform: 'rotate(0deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
expandOpen: {
transform: 'rotate(180deg)',
},
avatar: {
fontSize: '1.0rem',
backgroundColor: red[500],
},
}),
);
interface Props {
platformName: string;
title: string;
repository: string;
description: string;
index: number;
onClick: (i: number, repo: string) => void;
activeIndex: number;
}
const ClusterTemplateCard: FC<Props> = props => {
const classes = useStyles();
const handleSelect = () => {
props.onClick(props.index, props.repository);
};
return (
<Card className={classes.root}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{props.platformName}
</Avatar>
}
action={<IconButton aria-label="settings" />}
title={props.title}
subheader={props.repository}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{props.description}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="select" onClick={handleSelect}>
{props.activeIndex === props.index ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon />
)}
</IconButton>
</CardActions>
</Card>
);
};
export default ClusterTemplateCard;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default } from './ClusterTemplateCard';
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC } from 'react';
import { Grid } from '@material-ui/core';
import ClusterTemplateCard from '../ClusterTemplateCard';
interface Props {
template: {
platformName: string;
title: string;
repository: string;
description: string;
}[];
}
const ClusterTemplateCardList: FC<Props> = props => {
const [activeIndex, setActiveIndex] = React.useState(-1);
const handleClicked = (index: number, repository: string) => {
setActiveIndex(index);
window.localStorage.setItem('gitops-template-repo', repository);
};
return (
<Grid container xl={12} spacing={4}>
{props.template.map((value, index) => (
<Grid item xl={2} key={index}>
<ClusterTemplateCard
activeIndex={activeIndex}
onClick={handleClicked}
index={index}
key={index}
platformName={value.platformName}
title={value.title}
repository={value.repository}
description={value.description}
/>
</Grid>
))}
</Grid>
);
};
export default ClusterTemplateCardList;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default } from './ClusterTemplateCardList';
@@ -0,0 +1,109 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC, useState } from 'react';
import {
Avatar,
Card,
CardActions,
CardContent,
CardHeader,
createStyles,
IconButton,
Theme,
Typography,
} from '@material-ui/core';
import { green } from '@material-ui/core/colors';
import { makeStyles } from '@material-ui/core/styles';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
maxWidth: 345,
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
expand: {
transform: 'rotate(0deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
},
expandOpen: {
transform: 'rotate(180deg)',
},
avatar: {
backgroundColor: green[500],
},
}),
);
interface Props {
shortName: string;
title: string;
repository: string;
description: string;
index: number;
onClick: (i: number, repository: string) => void;
selections: Set<number>;
}
const ProfileCard: FC<Props> = props => {
const [selection, setSelection] = useState(false);
const handleSelect = () => {
props.onClick(props.index, props.repository);
setSelection(props.selections.has(props.index));
};
const classes = useStyles();
return (
<Card className={classes.root}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{props.shortName}
</Avatar>
}
action={<IconButton aria-label="settings" />}
title={props.title}
subheader={props.repository.replace('https://github.com/', '')}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{props.description}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="select" onClick={handleSelect}>
{selection ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon />
)}
</IconButton>
</CardActions>
</Card>
);
};
export default ProfileCard;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default } from './ProfileCard';
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC, useState } from 'react';
import { Grid } from '@material-ui/core';
import ProfileCard from '../ProfileCard';
interface Props {
profileTemplates: {
shortName: string;
title: string;
repository: string;
description: string;
}[];
}
const ProfileCardList: FC<Props> = props => {
const [selections, setSelections] = useState<Set<number>>(new Set<number>());
const [profiles, setProfiles] = useState<Set<string>>(new Set<string>());
const handleClicked = (index: number, repository: string) => {
if (selections.has(index)) {
selections.delete(index);
profiles.delete(repository);
} else {
selections.add(index);
profiles.add(repository);
}
setSelections(selections);
setProfiles(profiles);
window.localStorage.setItem(
'gitops-profiles',
JSON.stringify(Array.from(profiles)),
);
};
return (
<Grid container xl={12} spacing={4}>
{props.profileTemplates.map((value, index) => (
<Grid item xl={2} key={index}>
<ProfileCard
shortName={value.shortName}
selections={selections}
onClick={handleClicked}
key={index}
index={index}
title={value.title}
repository={value.repository}
description={value.description}
/>
</Grid>
))}
</Grid>
);
};
export default ProfileCardList;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default } from './ProfileCardList';
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { render } from '@testing-library/react';
import mockFetch from 'jest-fetch-mock';
import ProfileCatalog from './ProfileCatalog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
describe('ProfileCatalog', () => {
it('should render', () => {
const apis = ApiRegistry.from([
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
]);
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
<ThemeProvider theme={lightTheme}>
<ApiProvider apis={apis}>
<ProfileCatalog />
</ApiProvider>
</ThemeProvider>,
);
expect(
rendered.getByText('Create GitOps-managed Cluster'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,335 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC, useEffect, useState } from 'react';
import {
Header,
Page,
pageTheme,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
SimpleStepper,
SimpleStepperStep,
InfoCard,
Progress,
Table,
StatusWarning,
StatusOK,
StatusRunning,
StatusError,
StatusPending,
StatusAborted,
useApi,
} from '@backstage/core';
import { TextField, List, ListItem, Link } from '@material-ui/core';
import ClusterTemplateCardList from '../ClusterTemplateCardList';
import ProfileCardList from '../ProfileCardList';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
// OK = (completed, success)
// Error = (?,failure)
// Aborted = (?,cancelled)
// Error = (?,timed_out)
// Warning = (?, skipped)
// Running = (queued, ?)
// Running = (in_progress,?)
export const transformStatus = (value: Status): JSX.Element => {
let status: JSX.Element = <StatusRunning>Unknown</StatusRunning>;
if (value.status === 'completed' && value.conclusion === 'success') {
status = <StatusOK>Success</StatusOK>;
} else if (value.conclusion === 'failure') {
status = <StatusError>Failure</StatusError>;
} else if (value.conclusion === 'cancelled') {
status = <StatusAborted>Cancelled</StatusAborted>;
} else if (value.conclusion === 'timed_out') {
status = <StatusError>Timed out</StatusError>;
} else if (value.conclusion === 'skipped') {
status = <StatusWarning>Skipped</StatusWarning>;
} else if (value.status === 'queued') {
status = <StatusPending>Queued</StatusPending>;
} else if (value.status === 'in_progress') {
status = <StatusRunning>In Progress</StatusRunning>;
}
return status;
};
export const transformRunStatus = (x: Status[]) => {
return x.map(value => {
return {
status: transformStatus(value),
message: value.message,
};
});
};
const ProfileCatalog: FC<{}> = () => {
// TODO: get data from REST API
const [clusterTemplates] = React.useState([
{
platformName: '15m',
title: 'EKS 2 workers',
repository: 'chanwit/eks-cluster-template',
description: 'EKS with Kubernetes 1.16 / 2 nodes of m5.xlarge (15 mins)',
},
{
platformName: '15m',
title: 'EKS 1 worker',
repository: 'chanwit/template-2',
description: 'EKS with Kubernetes 1.16 / 1 node of m5.xlarge (15 mins)',
},
]);
const [profileTemplates] = React.useState([
{
shortName: 'ml',
title: 'MLOps',
repository: 'https://github.com/weaveworks/mlops-profile',
description: 'Kubeflow-based Machine Learning pipeline',
},
{
shortName: 'ai',
title: 'COVID ML',
repository: 'https://github.com/weaveworks/covid-ml-profile',
description: 'Fk-covid Application profile',
},
]);
const [loginInfo] = useLocalStorage('githubLoginDetails', {
name: 'Guest',
username: '',
token: '',
});
const [templateRepo] = useLocalStorage<string>('gitops-template-repo');
const [gitopsProfiles] = useLocalStorage<string[]>('gitops-profiles');
const [showProgress, setShowProgress] = useState(false);
const [pollingLog, setPollingLog] = useState(false);
const [gitHubOrg, setGitHubOrg] = useState(loginInfo.username);
const [gitHubRepo, setGitHubRepo] = useState('new-cluster');
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
const [runStatus, setRunStatus] = useState<Status[]>([]);
const [runLink, setRunLink] = useState<string>('');
const api = useApi(gitOpsApiRef);
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
return () => {};
}, [pollingLog, api, gitHubOrg, gitHubRepo, loginInfo]);
const showFailureMessage = (msg: string) => {
setRunStatus(
runStatus.concat([
{
status: 'completed',
message: msg,
conclusion: 'failure',
},
]),
);
};
const showSuccessMessage = (msg: string) => {
setRunStatus(
runStatus.concat([
{
status: 'completed',
message: msg,
conclusion: 'success',
},
]),
);
};
const doCreateCluster = async () => {
setShowProgress(true);
setRunStatus([]);
const cloneResponse = await api.cloneClusterFromTemplate({
templateRepository: templateRepo,
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
secrets: {
awsAccessKeyId: awsAccessKeyId,
awsSecretAccessKey: awsSecretAccessKey,
},
});
if (cloneResponse.error === undefined) {
showSuccessMessage('Forked new cluster repo');
} else {
setShowProgress(false);
showFailureMessage(cloneResponse.error);
}
const applyProfileResp = await api.applyProfiles({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
profiles: gitopsProfiles,
});
if (applyProfileResp.error === undefined) {
showSuccessMessage('Applied profiles to the repo');
} else {
setShowProgress(false);
showFailureMessage(applyProfileResp.error);
}
const clusterStateResp = await api.changeClusterState({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
clusterState: 'present',
});
if (clusterStateResp.error === undefined) {
// cluster creation start, so start pulling log
setPollingLog(true);
showSuccessMessage('Changed desired cluster state to present');
} else {
setPollingLog(false);
setShowProgress(false);
showFailureMessage(clusterStateResp.error);
}
};
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'message', title: 'Message' },
];
return (
<Page theme={pageTheme.tool}>
<Header
title="Create GitOps-managed Cluster"
subtitle="Kubernetes cluster with ready-to-use profiles"
>
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
<Content>
<ContentHeader title="Create Cluster">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<SimpleStepper>
<SimpleStepperStep title="Choose Cluster Template">
<ClusterTemplateCardList template={clusterTemplates} />
</SimpleStepperStep>
<SimpleStepperStep title="Select GitOps Profile">
<ProfileCardList profileTemplates={profileTemplates} />
</SimpleStepperStep>
<SimpleStepperStep
title="Create Cluster"
actions={{ nextText: 'Create', onNext: () => doCreateCluster() }}
>
<InfoCard>
<List>
<ListItem>
<TextField
name="github-org-tf"
label="GitHub Organization"
defaultValue={gitHubOrg}
required
onChange={e => {
setGitHubOrg(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="github-repo-tf"
label="New Repository"
defaultValue={gitHubRepo}
required
onChange={e => {
setGitHubRepo(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="aws-access-key-id-tf"
label="Access Key ID"
required
type="password"
onChange={e => {
setAwsAccessKeyId(e.target.value);
}}
/>
</ListItem>
<ListItem>
<TextField
name="aws-secret-access-key-tf"
label="Secret Access Key"
required
type="password"
onChange={e => {
setAwsSecretAccessKey(e.target.value);
}}
/>
</ListItem>
</List>
</InfoCard>
</SimpleStepperStep>
</SimpleStepper>
<div>
<Progress hidden={!showProgress} />
<Table
options={{ search: false, paging: false, toolbar: false }}
data={transformRunStatus(runStatus)}
columns={columns}
/>
<Link
hidden={runLink === ''}
rel="noopener noreferrer"
href={`${runLink}?check_suite_focus=true`}
target="_blank"
>
Details
</Link>
</div>
</Content>
</Page>
);
};
export default ProfileCatalog;
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default, transformRunStatus } from './ProfileCatalog';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { plugin } from './plugin';
export * from './api';
@@ -0,0 +1,23 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { plugin } from './plugin';
describe('gitops-profiles', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createPlugin } from '@backstage/core';
import ProfileCatalog from './components/ProfileCatalog';
import ClusterPage from './components/ClusterPage';
import ClusterList from './components/ClusterList';
export const plugin = createPlugin({
id: 'gitops-profiles',
register({ router }) {
router.registerRoute('/gitops-clusters', ClusterList);
router.registerRoute('/gitops-cluster/:owner/:repo', ClusterPage);
router.registerRoute('/gitops-cluster-create', ProfileCatalog);
},
});
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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';
require('jest-fetch-mock').enableMocks();
@@ -29,9 +29,10 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const setup = () => ({
+5 -1
View File
@@ -69,6 +69,7 @@ export interface TechRadarComponentProps {
export interface TechRadarApi extends TechRadarComponentProps {
title?: string;
subtitle?: string;
pageTitle?: string;
}
export const techRadarApiRef = createApiRef<TechRadarApi>({
@@ -84,11 +85,13 @@ export class TechRadar implements TechRadarApi {
public svgProps: TechRadarApi['svgProps'];
public title: TechRadarApi['title'];
public subtitle: TechRadarApi['subtitle'];
public pageTitle: TechRadarApi['pageTitle'];
constructor(overrideOptions: TechRadarApi) {
const defaultOptions: Partial<TechRadarApi> = {
title: 'Tech Radar',
subtitle: 'Welcome to the Tech Radar!',
subtitle: 'Pick the recommended technologies for your projects',
pageTitle: 'Company Radar',
};
const options = { ...defaultOptions, ...overrideOptions };
@@ -99,5 +102,6 @@ export class TechRadar implements TechRadarApi {
this.svgProps = options.svgProps;
this.title = options.title;
this.subtitle = options.subtitle;
this.pageTitle = options.pageTitle;
}
}
@@ -83,7 +83,9 @@ describe('RadarPage', () => {
await waitForElement(() => getByTestId('tech-radar-svg'));
expect(getByText('Welcome to the Tech Radar!')).toBeInTheDocument();
expect(
getByText('Pick the recommended technologies for your projects'),
).toBeInTheDocument();
expect(getByTestId('tech-radar-svg')).toBeInTheDocument();
});
@@ -16,7 +16,16 @@
import React, { FC } from 'react';
import { Grid } from '@material-ui/core';
import { Page, Header, Content, pageTheme, useApi } from '@backstage/core';
import {
Content,
ContentHeader,
Page,
Header,
HeaderLabel,
SupportButton,
pageTheme,
useApi,
} from '@backstage/core';
import RadarComponent from '../components/RadarComponent';
import { techRadarApiRef, TechRadarApi } from '../api';
@@ -24,9 +33,19 @@ const RadarPage: FC<{}> = () => {
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
return (
<Page theme={pageTheme.home}>
<Header title={techRadarApi.title} subtitle={techRadarApi.subtitle} />
<Page theme={pageTheme.tool}>
<Header title={techRadarApi.title} subtitle={techRadarApi.subtitle}>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Beta" />
</Header>
<Content>
<ContentHeader title={techRadarApi.pageTitle}>
<SupportButton>
This is used for visualizing the official guidelines of different
areas of software development such as languages, frameworks,
infrastructure and processes.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="row">
<Grid item xs={12} sm={6} md={4}>
<RadarComponent {...techRadarApi} />
+18
View File
@@ -88,6 +88,24 @@ entries.push({
quadrant: { id: 'process', name: 'Process' },
ring: { id: 'assess', name: 'ASSESS', color: '#fbdb84' },
});
entries.push({
moved: 0,
url: '#',
key: 'docs-like-code',
id: 'docs-like-code',
title: 'Docs-like-code',
quadrant: { id: 'process', name: 'Process' },
ring: { id: 'use', name: 'USE', color: '#93c47d' },
});
entries.push({
moved: 0,
url: '#',
key: 'force-push',
id: 'force-push',
title: 'Force push to master',
quadrant: { id: 'process', name: 'Process' },
ring: { id: 'hold', name: 'HOLD', color: '#93c47d' },
});
entries.push({
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },