Merge branch 'master' into mob/use-entity-list-provider-in-scaffolder-page
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
Do not throw in `ScmIntegration` `byUrl` for invalid URLs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Improve UX of the Sidebar by adding SidebarScrollWrapper component allowing the user to scroll through Plugins & Shortcuts on smaller screens. Prevent the Sidebar from opening on click on small devices
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-pagerduty': patch
|
||||
---
|
||||
|
||||
Update README
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
add default branch property for publish GitLab, Bitbucket and Azure actions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Added filesystem remove/rename built-in actions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
More helpful error message when trying to import by folder from non-github
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
- Enhanced core `Button` component to open external links in new tab.
|
||||
- Replaced the use of `Button` component from material by `core-components` in tools card.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-model': minor
|
||||
---
|
||||
|
||||
Changed the regex to validate names following the Kubernetes validation rule, this allow to be more permissive validating the name of the object in Backstage.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/techdocs-common': patch
|
||||
---
|
||||
|
||||
Fix openStack swift publisher encoding issue. Remove utf8 forced encoding on binary files
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Change catalog page layout to use Grid components to improve responsiveness
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Added a `readUrl` method to the `UrlReader` interface that allows for complex response objects and is intended to replace the `read` method. This new method is currently optional to implement which allows for a soft migration to `readUrl` instead of `read` in the future.
|
||||
|
||||
The main use case for `readUrl` returning an object instead of solely a read buffer is to allow for additional metadata such as ETag, which is a requirement for more efficient catalog processing.
|
||||
|
||||
The `GithubUrlReader` and `GitlabUrlReader` readers fully implement `readUrl`. The other existing readers implement the new method but do not propagate or return ETags.
|
||||
|
||||
While the `readUrl` method is not yet required, it will be in the future, and we already log deprecation warnings when custom `UrlReader` implementations that do not implement `readUrl` are used. We therefore recommend that any existing custom implementations are migrated to implement `readUrl`.
|
||||
|
||||
The old `read` and the new `readUrl` methods can easily be implemented using one another, but we recommend moving the chunk of the implementation to the new `readUrl` method as `read` is being removed, for example this:
|
||||
|
||||
```ts
|
||||
class CustomUrlReader implements UrlReader {
|
||||
read(url: string): Promise<Buffer> {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
// error handling ...
|
||||
}
|
||||
|
||||
return Buffer.from(await res.text());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Can be migrated to something like this:
|
||||
|
||||
```ts
|
||||
class CustomUrlReader implements UrlReader {
|
||||
read(url: string): Promise<Buffer> {
|
||||
const res = await this.readUrl(url);
|
||||
return res.buffer();
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
_options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
// error handling ...
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.text());
|
||||
return { buffer: async () => buffer };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
While there is no usage of the ETag capability yet in the main Backstage packages, you can already add it to your custom implementations. To do so, refer to the documentation of the `readUrl` method and surrounding types, and the existing implementation in `packages/backend-common/src/reading/GithubUrlReader.ts`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-ldap': minor
|
||||
---
|
||||
|
||||
Add extension points to the `LdapOrgReaderProcessor` to make it possible to do more advanced modifications
|
||||
of the ingested users and groups.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Show scroll bar of the sidebar wrapper only on hover
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-ilert': patch
|
||||
---
|
||||
|
||||
chore: bump `@date-io/luxon` from 1.3.13 to 2.10.11
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Make use of the new `readUrl` method on `UrlReader` from `@backstage/backend-common`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
Fix downloads from repositories located at bitbucket.org
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Use SidebarScrollWrapper to improve responsiveness of the current sidebar. Change: Wrap a section of SidebarItems with this component to enable scroll for smaller screens. It can also be used in sidebar plugins (see shortcuts plugin for an example).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Fix error in error panel, and console warnings about DOM nesting pre inside p
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-org': patch
|
||||
---
|
||||
|
||||
Add edit button to Group Profile Card
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Pass through the `idToken` in `Authorization` Header for `listActions` request
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
add defaultBranch property for publish GitHub action
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
removing mandatory of protection for the default branch, that could be handled by the GitHub automation in async manner, thus throwing floating errors
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Add custom styles to scroll bar of the sidebar wrapper to fix flaky behaviour
|
||||
@@ -137,6 +137,7 @@ maintainership
|
||||
makefile
|
||||
md
|
||||
memcache
|
||||
memoized
|
||||
microservice
|
||||
microservices
|
||||
microsite
|
||||
@@ -281,6 +282,7 @@ unregistration
|
||||
untracked
|
||||
upvote
|
||||
url
|
||||
URLs
|
||||
utils
|
||||
validator
|
||||
validators
|
||||
|
||||
@@ -36,3 +36,4 @@
|
||||
| [HelloFresh](https://www.hellofresh.de/) | [@iammuho](https://github.com/iammuho), [@ElenaForester](https://github.com/ElenaForester), [@diegomarangoni](https://github.com/diegomarangoni) | Our developer portal at HelloFresh - Spread across an organisation of 500+ engineers globally. |
|
||||
| [FactSet](https://www.factset.com/) | [@kuangp](https://github.com/kuangp) | Developer portal to provide discoverability to all internal components, APIs, documentation, and scaffold templates with integrations to our internal infrastructure tools. |
|
||||
| [Workrise](https://www.workrise.com/) | [Michael Rode](https://github.com/michaelrode) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
|
||||
| [RedVentures](https://www.redventures.com/) | [Chris Diaz](https://github.com/codingdiaz) | Developer portal that brings everything an engineer needs to provide value into a single pane of glass. |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# [Backstage](https://backstage.io)
|
||||
|
||||
_During the month of July the majority of the maintainers will be on summer vacation 🏖️ Development will continue as usual, but expect a slower pace for discussions and PR reviews. Why not take this opportunity to [build a plugin](https://backstage.io/docs/plugins/)?_
|
||||
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://www.cncf.io/projects)
|
||||
[](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22)
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Catalog', () => {
|
||||
|
||||
cy.visit('/catalog');
|
||||
|
||||
cy.contains('Owned (8)').should('be.visible');
|
||||
cy.contains('Owned (10)').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,10 @@ describe('Integrations', () => {
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit('/catalog');
|
||||
cy.contains('All').click();
|
||||
cy.get('[data-testid="user-picker-all"]').click();
|
||||
cy.get('table').should('contain', 'github-repo');
|
||||
cy.get('table').should('contain', 'github-repo-nested');
|
||||
});
|
||||
@@ -52,8 +54,10 @@ describe('Integrations', () => {
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit('/catalog');
|
||||
cy.contains('All').click();
|
||||
cy.get('[data-testid="user-picker-all"]').click();
|
||||
cy.get('table').should('contain', 'gitlab-repo');
|
||||
cy.get('table').should('contain', 'gitlab-repo-nested');
|
||||
});
|
||||
@@ -67,8 +71,10 @@ describe('Integrations', () => {
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
cy.wait(5000);
|
||||
|
||||
cy.visit('/catalog');
|
||||
cy.contains('All').click();
|
||||
cy.get('[data-testid="user-picker-all"]').click();
|
||||
cy.get('table').should('contain', 'bitbucket-repo');
|
||||
cy.get('table').should('contain', 'bitbucket-repo-nested');
|
||||
});
|
||||
|
||||
@@ -55,3 +55,20 @@ INFO - Start watching changes
|
||||
[I 210115 19:00:45 handlers:64] Start detecting changes
|
||||
INFO - Start detecting changes
|
||||
```
|
||||
|
||||
## PlantUML with `svg_object` doesn't render
|
||||
|
||||
The [plantuml-markdown](https://pypi.org/project/plantuml-markdown/) MkDocs
|
||||
plugin available in
|
||||
[`mkdocs-techdocs-core`](https://github.com/backstage/mkdocs-techdocs-core)
|
||||
supports different formats for rendering diagrams. TechDocs does however not
|
||||
support all of them.
|
||||
|
||||
The `svg_object` format renders a diagram as an HTML `<object>` tag but this is
|
||||
not allowed as it enables bad actors to inject malicious content into
|
||||
documentation pages. See
|
||||
[CVE-2021-32661](https://github.com/advisories/GHSA-gg96-f8wr-p89f) for more
|
||||
details.
|
||||
|
||||
Instead use `svg_inline` which renders as an `<svg>` tag and provides the same
|
||||
benefits as `svg_object`.
|
||||
|
||||
@@ -122,8 +122,8 @@ The DN under which users are stored, e.g.
|
||||
#### users.options
|
||||
|
||||
The search options to use when sending the query to the server, when reading all
|
||||
users. All of the options are shown below, with their default values, but they
|
||||
are all optional.
|
||||
users. All the options are shown below, with their default values, but they are
|
||||
all optional.
|
||||
|
||||
```yaml
|
||||
options:
|
||||
@@ -158,8 +158,8 @@ set:
|
||||
|
||||
Mappings from well known entity fields, to LDAP attribute names. This is where
|
||||
you are able to define how to interpret the attributes of each LDAP result item,
|
||||
and to move them into the corresponding entity fields. All of the options are
|
||||
shown below, with their default values, but they are all optional.
|
||||
and to move them into the corresponding entity fields. All the options are shown
|
||||
below, with their default values, but they are all optional.
|
||||
|
||||
If you leave out an optional mapping, it will still be copied using that default
|
||||
value. For example, even if you do not put in the field `displayName` in your
|
||||
@@ -204,8 +204,8 @@ The DN under which groups are stored, e.g.
|
||||
#### groups.options
|
||||
|
||||
The search options to use when sending the query to the server, when reading all
|
||||
groups. All of the options are shown below, with their default values, but they
|
||||
are all optional.
|
||||
groups. All the options are shown below, with their default values, but they are
|
||||
all optional.
|
||||
|
||||
```yaml
|
||||
options:
|
||||
@@ -282,3 +282,35 @@ map:
|
||||
# the spec.children field of the entity.
|
||||
members: member
|
||||
```
|
||||
|
||||
## Customize the Processor
|
||||
|
||||
In case you want to customize the ingested entities, the
|
||||
`LdapOrgReaderProcessor` allows to pass transformers for users and groups.
|
||||
|
||||
1. Create a transformer:
|
||||
|
||||
```ts
|
||||
export async function myGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
// Transformations may change namespace, change entity naming pattern, fill
|
||||
// profile with more or other details...
|
||||
|
||||
// Create the group entity on your own, or wrap the default transformer
|
||||
return await defaultGroupTransformer(vendor, config, group);
|
||||
}
|
||||
```
|
||||
|
||||
2. Configure the processor with the transformer:
|
||||
|
||||
```ts
|
||||
builder.addProcessor(
|
||||
LdapOrgReaderProcessor.fromConfig(config, {
|
||||
logger,
|
||||
groupTransformer: myGroupTransformer,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
id: url-reader
|
||||
title: URL Reader
|
||||
sidebar_label: URL Reader
|
||||
# prettier-ignore
|
||||
description: URL Reader is a backend core API responsible for reading files from external locations.
|
||||
---
|
||||
|
||||
## Concept
|
||||
|
||||
Some of the core plugins of Backstage have to read files from an external
|
||||
location. [Software Catalog](../features/software-catalog/index.md) has to read
|
||||
the [`catalog-info.yaml`](../features/software-catalog/descriptor-format.md)
|
||||
entity descriptor files to register and track an entity.
|
||||
[Software Templates](../features/software-templates/index.md) have to download
|
||||
the template skeleton files before creating a new component.
|
||||
[TechDocs](../features/techdocs/README.md) has to download the markdown source
|
||||
files before generating a documentation site.
|
||||
|
||||
Since, the requirement for reading files is so essential for Backstage plugins,
|
||||
the
|
||||
[`@backstage/backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common)
|
||||
package provides a dedicated API for reading from such URL based remote
|
||||
locations like GitHub, GitLab, Bitbucket, Google Cloud Storage, etc. This is
|
||||
commonly referred to as "URL Reader". It takes care of making authenticated
|
||||
requests to the remote host so that private files can be read securely. If users
|
||||
have [GitHub App based authentication](github-apps.md) set up, URL Reader even
|
||||
refreshes the token, to avoid reaching the GitHub API rate limit.
|
||||
|
||||
As a result, plugin authors do not have to worry about any of these problems
|
||||
when trying to read files.
|
||||
|
||||
## Interface
|
||||
|
||||
When the Backstage backend starts, a new instance of URL Reader is created. You
|
||||
can see this in the index file of your Backstage backend
|
||||
i.e.`packages/backend/src/index.ts`.
|
||||
[Example](https://github.com/backstage/backstage/blob/ebbe91dbe79038a61d35cf6ed2d96e0e0d5a15f3/packages/backend/src/index.ts#L57)
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/index.ts
|
||||
|
||||
import { URLReaders } from '@backstage/backend-common';
|
||||
|
||||
function makeCreateEnv(config: Config) {
|
||||
// ....
|
||||
const reader = UrlReaders.default({ logger, config });
|
||||
//
|
||||
}
|
||||
```
|
||||
|
||||
This instance contains all
|
||||
[the default URL Reader providers](https://github.com/backstage/backstage/blob/master/packages/backend-common/src/reading/UrlReaders.ts)
|
||||
in the backend-common package including GitHub, GitLab, Bitbucket, Azure, Google
|
||||
GCS. As the need arises, more URL Readers are being written to support different
|
||||
providers.
|
||||
|
||||
The generic interface of a URL Reader instance looks like this.
|
||||
|
||||
```ts
|
||||
export type UrlReader = {
|
||||
/* Used to read a single file and return its content. */
|
||||
read(url: string): Promise<Buffer>;
|
||||
/**
|
||||
* A replacement for the read method that supports options and complex responses.
|
||||
*
|
||||
* Use this whenever it is available, as the read method will be deprecated and
|
||||
* eventually removed in the future.
|
||||
*/
|
||||
readUrl?(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
|
||||
/* Used to read a file tree and download as a directory. */
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
/* Used to search a file in a tree. */
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
```
|
||||
|
||||
## Using a URL Reader inside a plugin
|
||||
|
||||
The `reader` instance is available in the backend plugin environment and passed
|
||||
on to all the backend plugins. You can see an
|
||||
[example](https://github.com/backstage/backstage/blob/b0be185369ebaad22255b7cdf18535d1d4ffd0e7/packages/backend/src/plugins/techdocs.ts#L31).
|
||||
When any of the methods on this instance is called with a URL, URL Reader
|
||||
extracts the host for that URL (e.g. `github.com`, `ghe.mycompany.com`, etc.).
|
||||
Using the
|
||||
[`@backstage/integration`](https://github.com/backstage/backstage/tree/master/packages/integration)
|
||||
package, it looks inside the
|
||||
[`integrations:`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/app-config.yaml#L134-L158)
|
||||
config of the `app-config.yaml` to find out how to work with the host based on
|
||||
the configs provided like authentication token, API base URL, etc.
|
||||
|
||||
Make sure your plugin-specific backend file at
|
||||
`packages/backend/src/plugins/<PLUGIN>.ts` is forwarding the `reader` instance
|
||||
passed on as the `PluginEnvironment` to the actual plugin's `createRouter`
|
||||
function. See how this is done in
|
||||
[Catalog](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend/src/plugins/catalog.ts#L25-L27)
|
||||
and
|
||||
[TechDocs](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend/src/plugins/techdocs.ts#L31-L36)
|
||||
backend plugins.
|
||||
|
||||
Once the reader instance is available inside the plugin, one of its methods can
|
||||
directly be used with a URL. Some example usages -
|
||||
|
||||
- [`read`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts#L24-L33) -
|
||||
Catalog using the `read` method to read the CODEOWNERS file in a repository.
|
||||
- [`readTree`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/techdocs-common/src/helpers.ts#L198-L220) -
|
||||
TechDocs using the `readTree` method to download markdown files in order to
|
||||
generate the documentation site.
|
||||
- [`readTree`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/techdocs-common/src/stages/prepare/url.ts#L33-L54) -
|
||||
TechDocs using `NotModifiedError` to maintain cache and speed up and limit the
|
||||
number of requests.
|
||||
- [`search`](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts#L88-L108) -
|
||||
Catalog using the `search` method to find files for a location URL containing
|
||||
a glob pattern.
|
||||
|
||||
## Writing a new URL Reader
|
||||
|
||||
If the available URL Readers are not sufficient for your use case and you want
|
||||
to add a new URL Reader for any other provider, you are most welcome to
|
||||
contribute one!
|
||||
|
||||
Feel free to use the
|
||||
[GitHub URL Reader](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/GithubUrlReader.ts)
|
||||
as a source of inspiration.
|
||||
|
||||
### 1. Add an integration
|
||||
|
||||
The provider for your new URL Reader can also be called an "integration" in
|
||||
Backstage. The `integrations:` section of your Backstage `app-config.yaml`
|
||||
config file is supposed to be the place where a Backstage integrator defines the
|
||||
host URL for the integration, authentication details and other integration
|
||||
related configurations.
|
||||
|
||||
The `@backstage/integration` package is where most of the integration specific
|
||||
code lives, so that it is shareable across Backstage. Functions like "read the
|
||||
integrations config and process it", "construct headers for authenticated
|
||||
requests to the host" or "convert a plain file URL into its API URL for
|
||||
downloading the file" would live in this package.
|
||||
|
||||
### 2. Create the URL Reader
|
||||
|
||||
Create a new class which implements the
|
||||
[`UrlReader` type](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/types.ts#L21-L28)
|
||||
inside `@backstage/backend-common`. Create and export a static `factory` method
|
||||
which reads the integration config and returns a map of host URLs the new reader
|
||||
should be used for. See the
|
||||
[GitHub URL Reader](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/GithubUrlReader.ts#L50-L63)
|
||||
for example.
|
||||
|
||||
### 3. Implement the methods
|
||||
|
||||
We want to make sure all URL Readers behave in the same way. Hence if possible,
|
||||
all the methods of the `UrlReader` interface should be implemented. However it
|
||||
is okay to start by implementing just one of them and create issues for the
|
||||
remaining.
|
||||
|
||||
#### read
|
||||
|
||||
NOTE: Use `readUrl` instead of `read`.
|
||||
|
||||
`read` method expects a user-friendly URL, something which can be copied from
|
||||
the browser naturally when a person is browsing the provider in their browser.
|
||||
|
||||
- ✅ Valid URL :
|
||||
`https://github.com/backstage/backstage/blob/master/ADOPTERS.md`
|
||||
- ❌ Not a valid URL :
|
||||
`https://raw.githubusercontent.com/backstage/backstage/master/ADOPTERS.md`
|
||||
- ❌ Not a valid URL : `https://github.com/backstage/backstage/ADOPTERS.md`
|
||||
|
||||
Upon receiving the URL, `read` converts the user-friendly URL into an API URL
|
||||
which can be used to request the provider's API.
|
||||
|
||||
`read` then makes an authenticated request to the provider API and returns the
|
||||
file's content.
|
||||
|
||||
#### readUrl
|
||||
|
||||
`readUrl` is a new interface that allows complex response objects and is
|
||||
intended to replace the `read` method. This new method is currently optional to
|
||||
implement which allows for a soft migration to `readUrl` instead of `read` in
|
||||
the future.
|
||||
|
||||
#### readTree
|
||||
|
||||
`readTree` method also expects user-friendly URLs similar to `read` but the URL
|
||||
should point to a tree (could be the root of a repository or even a
|
||||
sub-directory).
|
||||
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/docs`
|
||||
|
||||
Using the provider's API documentation, find out an API endpoint which can be
|
||||
used to download either a zip or a tarball. You can download the entire tree
|
||||
(e.g. a repository) and filter out in case the user is expecting only a
|
||||
sub-tree. But some APIs are smart enough to accept a path and return only a
|
||||
sub-tree in the downloaded archive.
|
||||
|
||||
#### search
|
||||
|
||||
`search` method expects a glob pattern of a URL and returns a list of files
|
||||
matching the query.
|
||||
|
||||
- ✅ Valid URL :
|
||||
`https://github.com/backstage/backstage/blob/master/**/catalog-info.yaml`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/**/*.md`
|
||||
- ✅ Valid URL :
|
||||
`https://github.com/backstage/backstage/blob/master/*/package.json`
|
||||
- ✅ Valid URL : `https://github.com/backstage/backstage/blob/master/READM`
|
||||
|
||||
The core logic of `readTree` can be used here to extract all the files inside
|
||||
the tree and return the files matching the pattern in the `url`.
|
||||
|
||||
### 4. Add to available URL Readers
|
||||
|
||||
There are two ways to make your new URL Reader available for use.
|
||||
|
||||
You can choose to make it open source, by updating the
|
||||
[`default` factory](https://github.com/backstage/backstage/blob/d5c83bb889b8142e343ebc4e4c0b90a02d1c1a3d/packages/backend-common/src/reading/UrlReaders.ts#L62-L81)
|
||||
method of URL Readers.
|
||||
|
||||
But for something internal which you don't want to make open source, you can
|
||||
update your `packages/backend/src/index.ts` file and update how the `reader`
|
||||
instance is created.
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/index.ts
|
||||
const reader = UrlReaders.default({
|
||||
logger: root,
|
||||
config,
|
||||
// This is where your internal URL Readers would go.
|
||||
factories: [myCustomReader.factory],
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Caching
|
||||
|
||||
All of the methods above support an ETag based caching. If the method is called
|
||||
without an `etag`, the response contains an ETag of the resource (should ideally
|
||||
forward the ETag returned by the provider). If the method is called with an
|
||||
`etag`, it first compares the ETag and returns a `NotModifiedError` in case the
|
||||
resource has not been modified. This approach is very similar to the actual
|
||||
[ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) and
|
||||
[If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
|
||||
HTTP headers.
|
||||
|
||||
### 6. Debugging
|
||||
|
||||
When debugging one of the URL Readers, you can straightforward use the
|
||||
[`reader` instance created](https://github.com/backstage/backstage/blob/ebbe91dbe79038a61d35cf6ed2d96e0e0d5a15f3/packages/backend/src/index.ts#L57)
|
||||
when the backend starts and call one of the methods with your debugging URL.
|
||||
|
||||
```ts
|
||||
// File: packages/backend/src/index.ts
|
||||
|
||||
async function main() {
|
||||
// ...
|
||||
const createEnv = makeCreateEnv(config);
|
||||
|
||||
const testReader = createEnv('test-url-reader').reader;
|
||||
const response = await testReader.readUrl(
|
||||
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
);
|
||||
console.log((await response.buffer()).toString());
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This will be run every time you restart the backend. Note that after any change
|
||||
in the URL Reader code, you need to kill the backend and restart, since the
|
||||
`reader` instance is memoized and does not update on hot module reloading. Also,
|
||||
there are a lot of unit tests written for the URL Readers, which you can make
|
||||
use of.
|
||||
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 536 KiB After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Harbor
|
||||
author: BESTSELLER
|
||||
authorUrl: bestsellerit.com
|
||||
category: Discovery
|
||||
description: This plugin will show you information about docker images within harbor.
|
||||
documentation: https://github.com/BESTSELLER/backstage-plugin-harbor/blob/master/README.md
|
||||
iconUrl: https://bestsellerit.com/img/terraform-harbor/goharbor.jpeg
|
||||
npmPackageName: '@bestsellerit/backstage-plugin-harbor'
|
||||
tags:
|
||||
- goharbor
|
||||
- harbor
|
||||
- docker
|
||||
@@ -166,7 +166,8 @@
|
||||
"plugins/proxying",
|
||||
"plugins/backend-plugin",
|
||||
"plugins/call-existing-api",
|
||||
"plugins/github-apps"
|
||||
"plugins/github-apps",
|
||||
"plugins/url-reader"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -137,6 +137,20 @@ td {
|
||||
color: $navigatorItemTextColor;
|
||||
}
|
||||
|
||||
/* search */
|
||||
[aria-expanded='true'] ~ .aa-without-1 {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
[aria-expanded='true'] ~ .aa-without-1 .aa-dataset-1:after {
|
||||
content: 'No results';
|
||||
}
|
||||
|
||||
.navSearchWrapper {
|
||||
/* Prevents the search box from detaching from its icon when pushed down */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* header */
|
||||
.fixedHeaderContainer {
|
||||
position: fixed;
|
||||
@@ -146,6 +160,12 @@ td {
|
||||
box-shadow: 3px 3px 8px rgba(38, 38, 38, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wrapper.headerWrapper {
|
||||
/* Keep the header stretched across the screen on the mid breakpoint, since we need a bit more space */
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1024px) {
|
||||
.fixedHeaderContainer {
|
||||
height: 66px;
|
||||
|
||||
@@ -112,6 +112,7 @@ nav:
|
||||
- Backend plugin: 'plugins/backend-plugin.md'
|
||||
- Call existing API: 'plugins/call-existing-api.md'
|
||||
- GitHub Apps for Backend Authentication: 'plugins/github-apps.md'
|
||||
- URL Reader: 'plugins/url-reader.md'
|
||||
- Testing:
|
||||
- Testing with Jest: 'plugins/testing.md'
|
||||
- Publishing:
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"command-exists": "^1.2.9",
|
||||
"concurrently": "^6.0.0",
|
||||
"eslint-plugin-notice": "^0.9.10",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"lerna": "^4.0.0",
|
||||
"lint-staged": "^10.1.0",
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
SidebarItem,
|
||||
SidebarDivider,
|
||||
SidebarSpace,
|
||||
SidebarScrollWrapper,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
@@ -88,10 +89,12 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
|
||||
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
|
||||
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
|
||||
</SidebarScrollWrapper>
|
||||
<SidebarDivider />
|
||||
<Shortcuts />
|
||||
<SidebarSpace />
|
||||
|
||||
@@ -42,6 +42,8 @@ export class AzureUrlReader implements UrlReader {
|
||||
// (undocumented)
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
// (undocumented)
|
||||
readUrl(url: string, _options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
// (undocumented)
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
@@ -59,6 +61,8 @@ export class BitbucketUrlReader implements UrlReader {
|
||||
// (undocumented)
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
// (undocumented)
|
||||
readUrl(url: string, _options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
// (undocumented)
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
@@ -184,8 +188,9 @@ export class Git {
|
||||
logger?: Logger | undefined;
|
||||
}) => Git;
|
||||
// (undocumented)
|
||||
init({ dir }: {
|
||||
init({ dir, defaultBranch, }: {
|
||||
dir: string;
|
||||
defaultBranch?: string;
|
||||
}): Promise<void>;
|
||||
// (undocumented)
|
||||
merge({ dir, theirs, ours, author, committer, }: {
|
||||
@@ -231,6 +236,8 @@ export class GithubUrlReader implements UrlReader {
|
||||
// (undocumented)
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
// (undocumented)
|
||||
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
// (undocumented)
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
@@ -248,6 +255,8 @@ export class GitlabUrlReader implements UrlReader {
|
||||
// (undocumented)
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
// (undocumented)
|
||||
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
// (undocumented)
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
// (undocumented)
|
||||
toString(): string;
|
||||
@@ -277,7 +286,7 @@ export type PluginEndpointDiscovery = {
|
||||
getExternalBaseUrl(pluginId: string): Promise<string>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export type ReadTreeResponse = {
|
||||
files(): Promise<ReadTreeResponseFile[]>;
|
||||
archive(): Promise<NodeJS.ReadableStream>;
|
||||
@@ -366,6 +375,7 @@ export interface StatusCheckHandlerOptions {
|
||||
// @public
|
||||
export type UrlReader = {
|
||||
read(url: string): Promise<Buffer>;
|
||||
readUrl?(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"dockerode": "^3.2.1",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"fs-extra": "^10.0.0",
|
||||
"git-url-parse": "^11.4.4",
|
||||
"helmet": "^4.0.0",
|
||||
"isomorphic-git": "^1.8.0",
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
SearchOptions,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
ReadUrlOptions,
|
||||
ReadUrlResponse,
|
||||
} from './types';
|
||||
|
||||
export class AzureUrlReader implements UrlReader {
|
||||
@@ -78,6 +80,15 @@ export class AzureUrlReader implements UrlReader {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
_options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
// TODO etag is not implemented yet.
|
||||
const buffer = await this.read(url);
|
||||
return { buffer: async () => buffer };
|
||||
}
|
||||
|
||||
async readTree(
|
||||
url: string,
|
||||
options?: ReadTreeOptions,
|
||||
|
||||
@@ -74,7 +74,24 @@ describe('BitbucketUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
describe('implementation', () => {
|
||||
describe('readUrl', () => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.body('foo')),
|
||||
),
|
||||
);
|
||||
|
||||
it('should be able to readUrl', async () => {
|
||||
const result = await bitbucketProcessor.readUrl(
|
||||
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
|
||||
);
|
||||
const buffer = await result.buffer();
|
||||
expect(buffer.toString()).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('rejects unknown targets', async () => {
|
||||
await expect(
|
||||
bitbucketProcessor.read('https://not.bitbucket.com/apa'),
|
||||
@@ -119,14 +136,14 @@ describe('BitbucketUrlReader', () => {
|
||||
),
|
||||
),
|
||||
rest.get(
|
||||
'https://bitbucket.org/backstage/mock/get/master.tgz',
|
||||
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/zip'),
|
||||
ctx.set(
|
||||
'content-disposition',
|
||||
'attachment; filename=backstage-mock-12ab34cd56ef.tgz',
|
||||
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
|
||||
),
|
||||
ctx.body(repoBuffer),
|
||||
),
|
||||
@@ -304,14 +321,14 @@ describe('BitbucketUrlReader', () => {
|
||||
),
|
||||
),
|
||||
rest.get(
|
||||
'https://bitbucket.org/backstage/mock/get/master.tgz',
|
||||
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/zip'),
|
||||
ctx.set(
|
||||
'content-disposition',
|
||||
'attachment; filename=backstage-mock-12ab34cd56ef.tgz',
|
||||
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
|
||||
),
|
||||
ctx.body(repoBuffer),
|
||||
),
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
SearchOptions,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
ReadUrlResponse,
|
||||
ReadUrlOptions,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -99,6 +101,15 @@ export class BitbucketUrlReader implements UrlReader {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
_options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
// TODO etag is not implemented yet.
|
||||
const buffer = await this.read(url);
|
||||
return { buffer: async () => buffer };
|
||||
}
|
||||
|
||||
async readTree(
|
||||
url: string,
|
||||
options?: ReadTreeOptions,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
ReaderFactory,
|
||||
ReadTreeResponse,
|
||||
ReadUrlOptions,
|
||||
ReadUrlResponse,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
} from './types';
|
||||
@@ -73,6 +75,15 @@ export class FetchUrlReader implements UrlReader {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
_options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
// TODO etag is not implemented yet.
|
||||
const buffer = await this.read(url);
|
||||
return { buffer: async () => buffer };
|
||||
}
|
||||
|
||||
async readTree(): Promise<ReadTreeResponse> {
|
||||
throw new Error('FetchUrlReader does not implement readTree');
|
||||
}
|
||||
|
||||
@@ -141,6 +141,113 @@ describe('GithubUrlReader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* readUrl
|
||||
*/
|
||||
describe('readUrl', () => {
|
||||
it('should use the headers from the credentials provider to the fetch request when doing readUrl', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
const mockHeaders = {
|
||||
Authorization: 'bearer blah',
|
||||
otherheader: 'something',
|
||||
};
|
||||
|
||||
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
|
||||
headers: mockHeaders,
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('authorization')).toBe(
|
||||
mockHeaders.Authorization,
|
||||
);
|
||||
expect(req.headers.get('otherheader')).toBe(
|
||||
mockHeaders.otherheader,
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/x-gzip'),
|
||||
ctx.body('foo'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await gheProcessor.readUrl(
|
||||
'https://github.com/backstage/mock/tree/blob/main',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotModified if GitHub responds with 304', async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
const mockHeaders = {
|
||||
Authorization: 'bearer blah',
|
||||
otherheader: 'something',
|
||||
};
|
||||
|
||||
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
|
||||
headers: mockHeaders,
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('authorization')).toBe(
|
||||
mockHeaders.Authorization,
|
||||
);
|
||||
expect(req.headers.get('otherheader')).toBe(
|
||||
mockHeaders.otherheader,
|
||||
);
|
||||
expect(req.headers.get('if-none-match')).toBe('foo');
|
||||
return res(
|
||||
ctx.status(304),
|
||||
ctx.set('Content-Type', 'application/x-gzip'),
|
||||
ctx.body('foo'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
gheProcessor.readUrl(
|
||||
'https://github.com/backstage/mock/tree/blob/main',
|
||||
{ etag: 'foo' },
|
||||
),
|
||||
).rejects.toThrow(NotModifiedError);
|
||||
});
|
||||
|
||||
it('should return etag from the response', async () => {
|
||||
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
|
||||
headers: {
|
||||
Authorization: 'bearer blah',
|
||||
},
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/',
|
||||
(_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Etag', 'foo'),
|
||||
ctx.body('bar'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const response = await gheProcessor.readUrl(
|
||||
'https://github.com/backstage/mock/tree/blob/main',
|
||||
);
|
||||
expect(response.etag).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* readTree
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
SearchResponse,
|
||||
SearchResponseFile,
|
||||
UrlReader,
|
||||
ReadUrlOptions,
|
||||
ReadUrlResponse,
|
||||
} from './types';
|
||||
|
||||
export type GhRepoResponse = RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
@@ -77,6 +79,14 @@ export class GithubUrlReader implements UrlReader {
|
||||
}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const response = await this.readUrl(url);
|
||||
return response.buffer();
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
const ghUrl = getGitHubFileFetchUrl(url, this.integration.config);
|
||||
const { headers } = await this.deps.credentialsProvider.getCredentials({
|
||||
url,
|
||||
@@ -86,6 +96,7 @@ export class GithubUrlReader implements UrlReader {
|
||||
response = await fetch(ghUrl.toString(), {
|
||||
headers: {
|
||||
...headers,
|
||||
...(options?.etag && { 'If-None-Match': options.etag }),
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
},
|
||||
});
|
||||
@@ -93,8 +104,15 @@ export class GithubUrlReader implements UrlReader {
|
||||
throw new Error(`Unable to read ${url}, ${e}`);
|
||||
}
|
||||
|
||||
if (response.status === 304) {
|
||||
throw new NotModifiedError();
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return Buffer.from(await response.text());
|
||||
return {
|
||||
buffer: async () => Buffer.from(await response.text()),
|
||||
etag: response.headers.get('ETag') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('GitlabUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
describe('implementation', () => {
|
||||
describe('read', () => {
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
|
||||
@@ -180,6 +180,53 @@ describe('GitlabUrlReader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('readUrl', () => {
|
||||
const [{ reader }] = GitlabUrlReader.factory({
|
||||
config: new ConfigReader({}),
|
||||
logger,
|
||||
treeResponseFactory,
|
||||
});
|
||||
|
||||
it('should throw NotModified on HTTP 304', async () => {
|
||||
worker.use(
|
||||
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ id: 12345 })),
|
||||
),
|
||||
rest.get('*', (req, res, ctx) => {
|
||||
expect(req.headers.get('If-None-Match')).toBe('999');
|
||||
return res(ctx.status(304));
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
reader.readUrl!(
|
||||
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
|
||||
{
|
||||
etag: '999',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(NotModifiedError);
|
||||
});
|
||||
|
||||
it('should return etag in response', async () => {
|
||||
worker.use(
|
||||
rest.get('*/api/v4/projects/:name', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ id: 12345 })),
|
||||
),
|
||||
rest.get('*', (_req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.set('ETag', '999'), ctx.body('foo'));
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await reader.readUrl!(
|
||||
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
|
||||
);
|
||||
expect(result.etag).toBe('999');
|
||||
const content = await result.buffer();
|
||||
expect(content.toString()).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTree', () => {
|
||||
const archiveBuffer = fs.readFileSync(
|
||||
path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.tar.gz'),
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
SearchOptions,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
ReadUrlResponse,
|
||||
ReadUrlOptions,
|
||||
} from './types';
|
||||
|
||||
export class GitlabUrlReader implements UrlReader {
|
||||
@@ -54,20 +56,37 @@ export class GitlabUrlReader implements UrlReader {
|
||||
) {}
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const response = await this.readUrl(url);
|
||||
return response.buffer();
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
builtUrl,
|
||||
getGitLabRequestOptions(this.integration.config),
|
||||
);
|
||||
response = await fetch(builtUrl, {
|
||||
headers: {
|
||||
...getGitLabRequestOptions(this.integration.config).headers,
|
||||
...(options?.etag && { 'If-None-Match': options.etag }),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to read ${url}, ${e}`);
|
||||
}
|
||||
|
||||
if (response.status === 304) {
|
||||
throw new NotModifiedError();
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return Buffer.from(await response.text());
|
||||
return {
|
||||
buffer: async () => Buffer.from(await response.text()),
|
||||
etag: response.headers.get('ETag') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`;
|
||||
|
||||
@@ -18,6 +18,8 @@ import { Storage } from '@google-cloud/storage';
|
||||
import {
|
||||
ReaderFactory,
|
||||
ReadTreeResponse,
|
||||
ReadUrlOptions,
|
||||
ReadUrlResponse,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
} from './types';
|
||||
@@ -90,6 +92,15 @@ export class GoogleGcsUrlReader implements UrlReader {
|
||||
}
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
_options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
// TODO etag is not implemented yet.
|
||||
const buffer = await this.read(url);
|
||||
return { buffer: async () => buffer };
|
||||
}
|
||||
|
||||
async readTree(): Promise<ReadTreeResponse> {
|
||||
throw new Error('GcsUrlReader does not implement readTree');
|
||||
}
|
||||
|
||||
@@ -15,27 +15,35 @@
|
||||
*/
|
||||
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
ReadTreeOptions,
|
||||
ReadTreeResponse,
|
||||
ReadUrlOptions,
|
||||
ReadUrlResponse,
|
||||
SearchOptions,
|
||||
SearchResponse,
|
||||
UrlReader,
|
||||
UrlReaderPredicateTuple,
|
||||
} from './types';
|
||||
|
||||
const MIN_WARNING_INTERVAL_MS = 1000 * 60 * 15;
|
||||
|
||||
/**
|
||||
* A UrlReader implementation that selects from a set of UrlReaders
|
||||
* based on a predicate tied to each reader.
|
||||
*/
|
||||
export class UrlReaderPredicateMux implements UrlReader {
|
||||
private readonly readers: UrlReaderPredicateTuple[] = [];
|
||||
private readonly readerWarnings: Map<UrlReader, number> = new Map();
|
||||
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
register(tuple: UrlReaderPredicateTuple): void {
|
||||
this.readers.push(tuple);
|
||||
}
|
||||
|
||||
read(url: string): Promise<Buffer> {
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const parsed = new URL(url);
|
||||
|
||||
for (const { predicate, reader } of this.readers) {
|
||||
@@ -47,6 +55,37 @@ export class UrlReaderPredicateMux implements UrlReader {
|
||||
throw new NotAllowedError(`Reading from '${url}' is not allowed`);
|
||||
}
|
||||
|
||||
async readUrl(
|
||||
url: string,
|
||||
options?: ReadUrlOptions,
|
||||
): Promise<ReadUrlResponse> {
|
||||
const parsed = new URL(url);
|
||||
|
||||
for (const { predicate, reader } of this.readers) {
|
||||
if (predicate(parsed)) {
|
||||
if (reader.readUrl) {
|
||||
return reader.readUrl(url, options);
|
||||
}
|
||||
const now = Date.now();
|
||||
const lastWarned = this.readerWarnings.get(reader) ?? 0;
|
||||
if (now > lastWarned + MIN_WARNING_INTERVAL_MS) {
|
||||
this.readerWarnings.set(reader, now);
|
||||
this.logger.warn(
|
||||
`No implementation of readUrl found for ${reader}, this method will be required in the ` +
|
||||
`future and will replace the 'read' method. See the changelog for more details here: ` +
|
||||
'https://github.com/backstage/backstage/blob/master/packages/backend-common/CHANGELOG.md#085',
|
||||
);
|
||||
}
|
||||
const buffer = await reader.read(url);
|
||||
return {
|
||||
buffer: async () => buffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new NotAllowedError(`Reading from '${url}' is not allowed`);
|
||||
}
|
||||
|
||||
async readTree(
|
||||
url: string,
|
||||
options?: ReadTreeOptions,
|
||||
|
||||
@@ -43,7 +43,7 @@ export class UrlReaders {
|
||||
* Creates a UrlReader without any known types.
|
||||
*/
|
||||
static create({ logger, config, factories }: CreateOptions): UrlReader {
|
||||
const mux = new UrlReaderPredicateMux();
|
||||
const mux = new UrlReaderPredicateMux(logger);
|
||||
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
|
||||
config,
|
||||
});
|
||||
|
||||
@@ -22,8 +22,20 @@ import { Config } from '@backstage/config';
|
||||
* A generic interface for fetching plain data from URLs.
|
||||
*/
|
||||
export type UrlReader = {
|
||||
/* Used to read a single file and return its content. */
|
||||
read(url: string): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* A replacement for the read method that supports options and complex responses.
|
||||
*
|
||||
* Use this whenever it is available, as the read method will be deprecated and
|
||||
* eventually removed in the future.
|
||||
*/
|
||||
readUrl?(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
|
||||
|
||||
/* Used to read a file tree and download as a directory. */
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
/* Used to search a file in a tree using a glob pattern. */
|
||||
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
|
||||
};
|
||||
|
||||
@@ -42,6 +54,40 @@ export type ReaderFactory = (options: {
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
}) => UrlReaderPredicateTuple[];
|
||||
|
||||
/**
|
||||
* An options object for readUrl operations.
|
||||
*/
|
||||
export type ReadUrlOptions = {
|
||||
/**
|
||||
* An etag can be provided to check whether readUrl's response has changed from a previous execution.
|
||||
*
|
||||
* In the readUrl() response, an etag is returned along with the data. The etag is a unique identifer
|
||||
* of the data, usually the commit SHA or etag from the target.
|
||||
*
|
||||
* When an etag is given in ReadUrlOptions, readUrl will first compare the etag against the etag
|
||||
* on the target. If they match, readUrl will throw a NotModifiedError indicating that the readUrl
|
||||
* response will not differ from the previous response which included this particular etag. If they
|
||||
* do not match, readUrl will return the rest of ReadUrlResponse along with a new etag.
|
||||
*/
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A response object for readUrl operations.
|
||||
*/
|
||||
export type ReadUrlResponse = {
|
||||
/**
|
||||
* Returns the data that was read from the remote URL.
|
||||
*/
|
||||
buffer(): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* Etag returned by content provider.
|
||||
* Can be used to compare and cache responses when doing subsequent calls.
|
||||
*/
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* An options object for readTree operations.
|
||||
*/
|
||||
@@ -66,14 +112,17 @@ export type ReadTreeOptions = {
|
||||
* In the readTree() response, an etag is returned along with the tree blob. The etag is a unique identifer
|
||||
* of the tree blob, usually the commit SHA or etag from the target.
|
||||
*
|
||||
* When a etag is given in ReadTreeOptions, readTree will first compare the etag against the etag
|
||||
* When an etag is given in ReadTreeOptions, readTree will first compare the etag against the etag
|
||||
* on the target branch. If they match, readTree will throw a NotModifiedError indicating that the readTree
|
||||
* response will not differ from the previous response which included this particular etag. If they mismatch,
|
||||
* readTree will return the rest of ReadTreeResponse along with a new etag.
|
||||
* response will not differ from the previous response which included this particular etag. If they
|
||||
* do not match, readTree will return the rest of ReadTreeResponse along with a new etag.
|
||||
*/
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A response object for readTree operations.
|
||||
*/
|
||||
export type ReadTreeResponse = {
|
||||
/**
|
||||
* files() returns an array of all the files inside the tree and corresponding functions to read their content.
|
||||
@@ -87,7 +136,8 @@ export type ReadTreeResponse = {
|
||||
dir(options?: ReadTreeResponseDirOptions): Promise<string>;
|
||||
|
||||
/**
|
||||
* A unique identifer of the tree blob, usually the commit SHA or etag from the target.
|
||||
* Etag returned by content provider.
|
||||
* Can be used to compare and cache responses when doing subsequent calls.
|
||||
*/
|
||||
etag: string;
|
||||
};
|
||||
|
||||
@@ -201,14 +201,16 @@ describe('Git', () => {
|
||||
describe('init', () => {
|
||||
it('should call isomorphic-git with the correct arguments', async () => {
|
||||
const dir = '/some/mock/dir';
|
||||
const defaultBranch = 'master';
|
||||
|
||||
const git = Git.fromAuth({});
|
||||
|
||||
await git.init({ dir });
|
||||
await git.init({ dir, defaultBranch });
|
||||
|
||||
expect(isomorphic.init).toHaveBeenCalledWith({
|
||||
fs,
|
||||
dir,
|
||||
defaultBranch,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,12 +150,19 @@ export class Git {
|
||||
});
|
||||
}
|
||||
|
||||
async init({ dir }: { dir: string }): Promise<void> {
|
||||
async init({
|
||||
dir,
|
||||
defaultBranch = 'master',
|
||||
}: {
|
||||
dir: string;
|
||||
defaultBranch?: string;
|
||||
}): Promise<void> {
|
||||
this.config.logger?.info(`Init git repository {dir=${dir}}`);
|
||||
|
||||
return git.init({
|
||||
fs,
|
||||
dir,
|
||||
defaultBranch,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,10 @@ describe('KubernetesValidatorFunctions', () => {
|
||||
['a-b', true],
|
||||
['-a-b', false],
|
||||
['a-b-', false],
|
||||
['a--b', false],
|
||||
['a--b', true],
|
||||
['a_b', true],
|
||||
['a.b', true],
|
||||
['a..b', true],
|
||||
])(`isValidObjectName %p ? %p`, (value, matches) => {
|
||||
expect(KubernetesValidatorFunctions.isValidObjectName(value)).toBe(matches);
|
||||
});
|
||||
@@ -114,9 +115,10 @@ describe('KubernetesValidatorFunctions', () => {
|
||||
['a-b', true],
|
||||
['-a-b', false],
|
||||
['a-b-', false],
|
||||
['a--b', false],
|
||||
['a--b', true],
|
||||
['a_b', true],
|
||||
['a.b', true],
|
||||
['a..b', true],
|
||||
['a/a', true],
|
||||
['a-b.c/a', true],
|
||||
['a--b.c/a', false],
|
||||
@@ -150,9 +152,10 @@ describe('KubernetesValidatorFunctions', () => {
|
||||
['a-b', true],
|
||||
['-a-b', false],
|
||||
['a-b-', false],
|
||||
['a--b', false],
|
||||
['a--b', true],
|
||||
['a_b', true],
|
||||
['a.b', true],
|
||||
['a..b', true],
|
||||
])(`isValidLabelValue %p ? %p`, (value, matches) => {
|
||||
expect(KubernetesValidatorFunctions.isValidLabelValue(value)).toBe(matches);
|
||||
});
|
||||
@@ -169,9 +172,10 @@ describe('KubernetesValidatorFunctions', () => {
|
||||
['a-b', true],
|
||||
['-a-b', false],
|
||||
['a-b-', false],
|
||||
['a--b', false],
|
||||
['a--b', true],
|
||||
['a_b', true],
|
||||
['a.b', true],
|
||||
['a..b', true],
|
||||
['a/a', true],
|
||||
['a-b.c/a', true],
|
||||
['a--b.c/a', false],
|
||||
|
||||
@@ -48,7 +48,7 @@ export class KubernetesValidatorFunctions {
|
||||
typeof value === 'string' &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9A-Z]+([-_.][a-z0-9A-Z]+)*$/.test(value)
|
||||
/^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"express": "^4.17.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"fork-ts-checker-webpack-plugin": "^6.2.9",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"handlebars": "^4.7.3",
|
||||
"html-webpack-plugin": "^4.3.0",
|
||||
"inquirer": "^7.0.4",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@backstage/config": "^0.1.5",
|
||||
"@types/json-schema": "^7.0.6",
|
||||
"ajv": "^7.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-schema": "^0.3.0",
|
||||
"json-schema-merge-allof": "^0.8.1",
|
||||
"typescript-json-schema": "^0.50.1",
|
||||
|
||||
@@ -37,9 +37,16 @@ export type LinkProps = MaterialLinkProps &
|
||||
*/
|
||||
export const Link = React.forwardRef<any, LinkProps>((props, ref) => {
|
||||
const to = String(props.to);
|
||||
return isExternalUri(to) ? (
|
||||
const external = isExternalUri(to);
|
||||
const newWindow = external && !!/^https?:/.exec(to);
|
||||
return external ? (
|
||||
// External links
|
||||
<MaterialLink ref={ref} href={to} {...props} />
|
||||
<MaterialLink
|
||||
ref={ref}
|
||||
href={to}
|
||||
{...(newWindow ? { target: '_blank', rel: 'noopener' } : {})}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
// Interact with React Router for internal links
|
||||
<MaterialLink ref={ref} component={RouterLink} {...props} />
|
||||
|
||||
@@ -76,7 +76,7 @@ export const ResponseErrorPanel = ({
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Request"
|
||||
secondary={request}
|
||||
secondary={request ? `${requestString}` : undefined}
|
||||
/>
|
||||
<CopyTextButton text={requestString} />
|
||||
</ListItem>
|
||||
@@ -87,10 +87,9 @@ export const ResponseErrorPanel = ({
|
||||
<ListItemText
|
||||
classes={{ secondary: classes.text }}
|
||||
primary="Full Error as JSON"
|
||||
secondary={<CodeSnippet language="json" text={jsonString} />}
|
||||
/>
|
||||
<CopyTextButton text={jsonString} />
|
||||
</ListItem>
|
||||
<CodeSnippet language="json" text={jsonString} showCopyCodeButton />
|
||||
</>
|
||||
</ErrorPanel>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,6 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
},
|
||||
header: {
|
||||
display: 'inline-block',
|
||||
padding: theme.spacing(2, 2, 2, 2.5),
|
||||
},
|
||||
headerTitle: {
|
||||
@@ -121,6 +120,7 @@ type Props = {
|
||||
children?: ReactNode;
|
||||
headerStyle?: object;
|
||||
headerProps?: CardHeaderProps;
|
||||
action?: ReactNode;
|
||||
actionsClassName?: string;
|
||||
actions?: ReactNode;
|
||||
cardClassName?: string;
|
||||
@@ -141,6 +141,7 @@ export const InfoCard = ({
|
||||
children,
|
||||
headerStyle,
|
||||
headerProps,
|
||||
action,
|
||||
actionsClassName,
|
||||
actions,
|
||||
cardClassName,
|
||||
@@ -190,6 +191,7 @@ export const InfoCard = ({
|
||||
}}
|
||||
title={title}
|
||||
subheader={subheader}
|
||||
action={action}
|
||||
style={{ ...headerStyle }}
|
||||
titleTypographyProps={titleTypographyProps}
|
||||
{...headerProps}
|
||||
|
||||
@@ -25,7 +25,8 @@ const useStyles = makeStyles(() => ({
|
||||
"'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'",
|
||||
gridTemplateRows: 'auto auto 1fr',
|
||||
gridTemplateColumns: 'auto 1fr auto',
|
||||
minHeight: '100vh',
|
||||
height: '100vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { makeStyles, useMediaQuery } from '@material-ui/core';
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef, useState, useContext, PropsWithChildren } from 'react';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
@@ -82,6 +82,9 @@ export const Sidebar = ({
|
||||
children,
|
||||
}: PropsWithChildren<Props>) => {
|
||||
const classes = useStyles();
|
||||
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
|
||||
theme.breakpoints.down('md'),
|
||||
);
|
||||
const [state, setState] = useState(State.Closed);
|
||||
const hoverTimerRef = useRef<number>();
|
||||
const { isPinned } = useContext(SidebarPinStateContext);
|
||||
@@ -94,7 +97,7 @@ export const Sidebar = ({
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = undefined;
|
||||
}
|
||||
if (state !== State.Open) {
|
||||
if (state !== State.Open && !isSmallScreen) {
|
||||
hoverTimerRef.current = window.setTimeout(() => {
|
||||
hoverTimerRef.current = undefined;
|
||||
setState(State.Open);
|
||||
@@ -122,6 +125,8 @@ export const Sidebar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const isOpen = (state === State.Open && !isSmallScreen) || isPinned;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.root}
|
||||
@@ -133,13 +138,13 @@ export const Sidebar = ({
|
||||
>
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isOpen: state === State.Open || isPinned,
|
||||
isOpen,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(classes.drawer, {
|
||||
[classes.drawerPeek]: state === State.Peek,
|
||||
[classes.drawerOpen]: state === State.Open || isPinned,
|
||||
[classes.drawerOpen]: isOpen,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
makeStyles,
|
||||
styled,
|
||||
TextField,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
@@ -290,3 +292,32 @@ export const SidebarDivider = styled('hr')({
|
||||
border: 'none',
|
||||
margin: '12px 0px',
|
||||
});
|
||||
|
||||
const styledScrollbar = (theme: Theme): CreateCSSProperties => ({
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
width: '5px',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.text.hint,
|
||||
borderRadius: '5px',
|
||||
},
|
||||
});
|
||||
|
||||
export const SidebarScrollWrapper = styled('div')(({ theme }) => {
|
||||
const scrollbarStyles = styledScrollbar(theme);
|
||||
return {
|
||||
flex: '0 1 auto',
|
||||
overflowX: 'hidden',
|
||||
// 5px space to the right of the scrollbar
|
||||
width: 'calc(100% - 5px)',
|
||||
// Display at least one item in the container
|
||||
// Question: Can this be a config/theme variable - if so, which? :/
|
||||
minHeight: '48px',
|
||||
overflowY: 'hidden',
|
||||
'@media (hover: none)': scrollbarStyles,
|
||||
'&:hover': scrollbarStyles,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
SidebarSearchField,
|
||||
SidebarSpace,
|
||||
SidebarSpacer,
|
||||
SidebarScrollWrapper,
|
||||
} from './Items';
|
||||
export { IntroCard, SidebarIntro } from './Intro';
|
||||
export {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@backstage/cli-common": "^0.1.2",
|
||||
"chalk": "^4.0.0",
|
||||
"commander": "^6.1.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"handlebars": "^4.7.3",
|
||||
"inquirer": "^7.0.4",
|
||||
"ora": "^5.3.0",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
SidebarItem,
|
||||
SidebarDivider,
|
||||
SidebarSpace,
|
||||
SidebarScrollWrapper,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
@@ -82,7 +83,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
</SidebarScrollWrapper>
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarSettings />
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
"commander": "^6.1.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"github-slugger": "^1.3.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.0.3"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"chalk": "^4.0.0",
|
||||
"commander": "^6.1.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"handlebars": "^4.7.3",
|
||||
"pgtools": "^0.3.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('bitbucket core', () => {
|
||||
config,
|
||||
);
|
||||
expect(result).toEqual(
|
||||
'https://bitbucket.org/backstage/mock/get/master.tgz',
|
||||
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function getBitbucketDownloadUrl(
|
||||
// /docs/index.md will download the docs folder and everything below it
|
||||
const path = filepath ? `&path=${encodeURIComponent(filepath)}` : '';
|
||||
const archiveUrl = isHosted
|
||||
? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tgz`
|
||||
? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tar.gz`
|
||||
: `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=tgz&at=${branch}&prefix=${project}-${repoName}${path}`;
|
||||
|
||||
return archiveUrl;
|
||||
|
||||
@@ -44,8 +44,12 @@ export function basicIntegrations<T extends ScmIntegration>(
|
||||
return integrations;
|
||||
},
|
||||
byUrl(url: string | URL): T | undefined {
|
||||
const parsed = typeof url === 'string' ? new URL(url) : url;
|
||||
return integrations.find(i => getHost(i) === parsed.hostname);
|
||||
try {
|
||||
const parsed = typeof url === 'string' ? new URL(url) : url;
|
||||
return integrations.find(i => getHost(i) === parsed.hostname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
byHost(host: string): T | undefined {
|
||||
return integrations.find(i => getHost(i) === host);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"aws-sdk": "^2.840.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
"fs-extra": "^10.0.0",
|
||||
"git-url-parse": "^11.4.4",
|
||||
"js-yaml": "^4.0.0",
|
||||
"json5": "^2.1.3",
|
||||
|
||||
@@ -156,7 +156,7 @@ export class OpenStackSwiftPublish implements PublisherBase {
|
||||
const uploadFile = limiter(
|
||||
() =>
|
||||
new Promise((res, rej) => {
|
||||
const readStream = fs.createReadStream(filePath, 'utf8');
|
||||
const readStream = fs.createReadStream(filePath);
|
||||
|
||||
const writeStream = this.storageClient.upload(params);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"express-session": "^1.17.1",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"got": "^11.5.2",
|
||||
"helmet": "^4.0.0",
|
||||
"jose": "^1.27.1",
|
||||
|
||||
@@ -16,6 +16,15 @@ import { SearchEntry } from 'ldapjs';
|
||||
import { SearchOptions } from 'ldapjs';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
// @public (undocumented)
|
||||
export function defaultGroupTransformer(vendor: LdapVendor, config: GroupConfig, entry: SearchEntry): Promise<GroupEntity | undefined>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function defaultUserTransformer(vendor: LdapVendor, config: UserConfig, entry: SearchEntry): Promise<UserEntity | undefined>;
|
||||
|
||||
// @public
|
||||
export type GroupTransformer = (vendor: LdapVendor, config: GroupConfig, group: SearchEntry) => Promise<GroupEntity | undefined>;
|
||||
|
||||
// @public
|
||||
export const LDAP_DN_ANNOTATION = "backstage.io/ldap-dn";
|
||||
|
||||
@@ -40,14 +49,18 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
});
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config, options: {
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}): LdapOrgReaderProcessor;
|
||||
// (undocumented)
|
||||
readLocation(location: LocationSpec, _optional: boolean, emit: CatalogProcessorEmit): Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
// @public
|
||||
export type LdapProviderConfig = {
|
||||
@@ -57,15 +70,25 @@ export type LdapProviderConfig = {
|
||||
groups: GroupConfig;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function mapStringAttr(entry: SearchEntry, vendor: LdapVendor, attributeName: string | undefined, setter: (value: string) => void): void;
|
||||
|
||||
// @public
|
||||
export function readLdapConfig(config: Config): LdapProviderConfig[];
|
||||
|
||||
// @public
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig): Promise<{
|
||||
export function readLdapOrg(client: LdapClient, userConfig: UserConfig, groupConfig: GroupConfig, options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
}): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type UserTransformer = (vendor: LdapVendor, config: UserConfig, user: SearchEntry) => Promise<UserEntity | undefined>;
|
||||
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
export { LdapClient } from './client';
|
||||
export { mapStringAttr } from './util';
|
||||
export { readLdapConfig } from './config';
|
||||
export type { LdapProviderConfig } from './config';
|
||||
export {
|
||||
@@ -22,4 +23,9 @@ export {
|
||||
LDAP_RDN_ANNOTATION,
|
||||
LDAP_UUID_ANNOTATION,
|
||||
} from './constants';
|
||||
export { readLdapOrg } from './read';
|
||||
export {
|
||||
defaultGroupTransformer,
|
||||
defaultUserTransformer,
|
||||
readLdapOrg,
|
||||
} from './read';
|
||||
export type { GroupTransformer, UserTransformer } from './types';
|
||||
|
||||
@@ -26,74 +26,97 @@ import {
|
||||
LDAP_UUID_ANNOTATION,
|
||||
} from './constants';
|
||||
import { LdapVendor } from './vendors';
|
||||
import { Logger } from 'winston';
|
||||
import { GroupTransformer, UserTransformer } from './types';
|
||||
import { mapStringAttr } from './util';
|
||||
|
||||
export async function defaultUserTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: UserConfig,
|
||||
entry: SearchEntry,
|
||||
): Promise<UserEntity | undefined> {
|
||||
const { set, map } = config;
|
||||
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
profile: {},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads users out of an LDAP provider.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param config The user data configuration
|
||||
* @param opts
|
||||
*/
|
||||
export async function readLdapUsers(
|
||||
client: LdapClient,
|
||||
config: UserConfig,
|
||||
opts?: { transformer?: UserTransformer },
|
||||
): Promise<{
|
||||
users: UserEntity[]; // With all relations empty
|
||||
userMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
|
||||
}> {
|
||||
const { dn, options, set, map } = config;
|
||||
const { dn, options, map } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
const entities: UserEntity[] = [];
|
||||
const userMemberOf: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
profile: {},
|
||||
memberOf: [],
|
||||
},
|
||||
};
|
||||
const transformer = opts?.transformer ?? defaultUserTransformer;
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
for (const user of entries) {
|
||||
const entity = await transformer(vendor, config, user);
|
||||
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(userMemberOf, myDn, vs);
|
||||
});
|
||||
|
||||
@@ -103,82 +126,103 @@ export async function readLdapUsers(
|
||||
return { users: entities, userMemberOf };
|
||||
}
|
||||
|
||||
export async function defaultGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
entry: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
const { set, map } = config;
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
profile: {},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.type, v => {
|
||||
entity.spec.type = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads groups out of an LDAP provider.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param config The group data configuration
|
||||
* @param opts
|
||||
*/
|
||||
export async function readLdapGroups(
|
||||
client: LdapClient,
|
||||
config: GroupConfig,
|
||||
opts?: {
|
||||
transformer?: GroupTransformer;
|
||||
},
|
||||
): Promise<{
|
||||
groups: GroupEntity[]; // With all relations empty
|
||||
groupMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
|
||||
groupMember: Map<string, Set<string>>; // DN -> DN or UUID of groups & users
|
||||
}> {
|
||||
const { dn, options, set, map } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
const groups: GroupEntity[] = [];
|
||||
const groupMemberOf: Map<string, Set<string>> = new Map();
|
||||
const groupMember: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const entry of entries) {
|
||||
const entity: GroupEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: '',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
profile: {},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
const { dn, map, options } = config;
|
||||
const vendor = await client.getVendor();
|
||||
|
||||
if (set) {
|
||||
for (const [path, value] of Object.entries(set)) {
|
||||
lodashSet(entity, path, value);
|
||||
}
|
||||
const transformer = opts?.transformer ?? defaultGroupTransformer;
|
||||
|
||||
const entries = await client.search(dn, options);
|
||||
|
||||
for (const group of entries) {
|
||||
const entity = await transformer(vendor, config, group);
|
||||
|
||||
if (!entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mapStringAttr(entry, vendor, map.name, v => {
|
||||
entity.metadata.name = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.description, v => {
|
||||
entity.metadata.description = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.rdn, v => {
|
||||
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
|
||||
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.type, v => {
|
||||
entity.spec.type = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.displayName, v => {
|
||||
entity.spec.profile!.displayName = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.email, v => {
|
||||
entity.spec.profile!.email = v;
|
||||
});
|
||||
mapStringAttr(entry, vendor, map.picture, v => {
|
||||
entity.spec.profile!.picture = v;
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
mapReferencesAttr(group, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(groupMemberOf, myDn, vs);
|
||||
});
|
||||
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
|
||||
mapReferencesAttr(group, vendor, map.members, (myDn, vs) => {
|
||||
ensureItems(groupMember, myDn, vs);
|
||||
});
|
||||
|
||||
@@ -199,22 +243,30 @@ export async function readLdapGroups(
|
||||
* with all relations etc filled in.
|
||||
*
|
||||
* @param client The LDAP client
|
||||
* @param logger A logger instance
|
||||
* @param userConfig The user data configuration
|
||||
* @param groupConfig The group data configuration
|
||||
* @param options
|
||||
*/
|
||||
export async function readLdapOrg(
|
||||
client: LdapClient,
|
||||
userConfig: UserConfig,
|
||||
groupConfig: GroupConfig,
|
||||
options: {
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
logger: Logger;
|
||||
},
|
||||
): Promise<{
|
||||
users: UserEntity[];
|
||||
groups: GroupEntity[];
|
||||
}> {
|
||||
const { users, userMemberOf } = await readLdapUsers(client, userConfig);
|
||||
const { users, userMemberOf } = await readLdapUsers(client, userConfig, {
|
||||
transformer: options?.userTransformer,
|
||||
});
|
||||
const { groups, groupMemberOf, groupMember } = await readLdapGroups(
|
||||
client,
|
||||
groupConfig,
|
||||
{ transformer: options?.groupTransformer },
|
||||
);
|
||||
|
||||
resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember);
|
||||
@@ -228,21 +280,6 @@ export async function readLdapOrg(
|
||||
// Helpers
|
||||
//
|
||||
|
||||
// Maps a single-valued attribute to a consumer
|
||||
function mapStringAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
setter: (value: string) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
const values = vendor.decodeStringAttribute(entry, attributeName);
|
||||
if (values && values.length === 1) {
|
||||
setter(values[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a multi-valued attribute of references to other objects, to a consumer
|
||||
function mapReferencesAttr(
|
||||
entry: SearchEntry,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
|
||||
import { SearchEntry } from 'ldapjs';
|
||||
import { LdapVendor } from './vendors';
|
||||
import { GroupConfig, UserConfig } from './config';
|
||||
|
||||
/**
|
||||
* Customize the ingested User entity
|
||||
*
|
||||
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
|
||||
* @param config The User specific config used by the default transformer.
|
||||
* @param user The found LDAP entry in its source format. This is the entry that you want to transform
|
||||
* @return A `UserEntity` or `undefined` if you want to ignore the found user for being ingested by the catalog
|
||||
*/
|
||||
export type UserTransformer = (
|
||||
vendor: LdapVendor,
|
||||
config: UserConfig,
|
||||
user: SearchEntry,
|
||||
) => Promise<UserEntity | undefined>;
|
||||
|
||||
/**
|
||||
* Customize the ingested Group entity
|
||||
*
|
||||
* @param vendor The LDAP vendor that can be used to find and decode vendor specific attributes
|
||||
* @param config The Group specific config used by the default transformer.
|
||||
* @param group The found LDAP entry in its source format. This is the entry that you want to transform
|
||||
* @return A `GroupEntity` or `undefined` if you want to ignore the found group for being ingested by the catalog
|
||||
*/
|
||||
export type GroupTransformer = (
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
) => Promise<GroupEntity | undefined>;
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Error as LDAPError } from 'ldapjs';
|
||||
import { Error as LDAPError, SearchEntry } from 'ldapjs';
|
||||
import { LdapVendor } from './vendors';
|
||||
|
||||
/**
|
||||
* Builds a string form of an LDAP Error structure.
|
||||
@@ -24,3 +25,25 @@ import { Error as LDAPError } from 'ldapjs';
|
||||
export function errorString(error: LDAPError) {
|
||||
return `${error.code} ${error.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single-valued attribute to a consumer
|
||||
*
|
||||
* @param entry The LDAP source entry
|
||||
* @param vendor The LDAP vendor
|
||||
* @param attributeName The source attribute to map. If the attribute is undefined the mapping will be silently ignored.
|
||||
* @param setter The function to be called with the decoded attribute from the source entry
|
||||
*/
|
||||
export function mapStringAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
setter: (value: string) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
const values = vendor.decodeStringAttribute(entry, attributeName);
|
||||
if (values && values.length === 1) {
|
||||
setter(values[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
GroupTransformer,
|
||||
LdapClient,
|
||||
LdapProviderConfig,
|
||||
readLdapConfig,
|
||||
readLdapOrg,
|
||||
UserTransformer,
|
||||
} from '../ldap';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
@@ -35,8 +37,17 @@ import {
|
||||
export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
private readonly providers: LdapProviderConfig[];
|
||||
private readonly logger: Logger;
|
||||
private readonly groupTransformer?: GroupTransformer;
|
||||
private readonly userTransformer?: UserTransformer;
|
||||
|
||||
static fromConfig(config: Config, options: { logger: Logger }) {
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
},
|
||||
) {
|
||||
const c = config.getOptionalConfig('catalog.processors.ldapOrg');
|
||||
return new LdapOrgReaderProcessor({
|
||||
...options,
|
||||
@@ -44,9 +55,16 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) {
|
||||
constructor(options: {
|
||||
providers: LdapProviderConfig[];
|
||||
logger: Logger;
|
||||
groupTransformer?: GroupTransformer;
|
||||
userTransformer?: UserTransformer;
|
||||
}) {
|
||||
this.providers = options.providers;
|
||||
this.logger = options.logger;
|
||||
this.groupTransformer = options.groupTransformer;
|
||||
this.userTransformer = options.userTransformer;
|
||||
}
|
||||
|
||||
async readLocation(
|
||||
@@ -81,6 +99,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor {
|
||||
client,
|
||||
provider.users,
|
||||
provider.groups,
|
||||
{
|
||||
groupTransformer: this.groupTransformer,
|
||||
userTransformer: this.userTransformer,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
|
||||
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fast-json-stable-stringify": "^2.1.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"git-url-parse": "^11.4.4",
|
||||
"glob": "^7.1.6",
|
||||
"knex": "^0.95.1",
|
||||
|
||||
@@ -94,12 +94,21 @@ export class PlaceholderProcessor implements CatalogProcessor {
|
||||
return [data, false];
|
||||
}
|
||||
|
||||
const read = async (url: string): Promise<Buffer> => {
|
||||
if (this.options.reader.readUrl) {
|
||||
const response = await this.options.reader.readUrl(url);
|
||||
const buffer = await response.buffer();
|
||||
return buffer;
|
||||
}
|
||||
return this.options.reader.read(url);
|
||||
};
|
||||
|
||||
return [
|
||||
await resolver({
|
||||
key: resolverKey,
|
||||
value: resolverValue,
|
||||
baseUrl: location.target,
|
||||
read: this.options.reader.read.bind(this.options.reader),
|
||||
read,
|
||||
}),
|
||||
true,
|
||||
];
|
||||
|
||||
@@ -101,7 +101,12 @@ export class UrlReaderProcessor implements CatalogProcessor {
|
||||
return Promise.all(output);
|
||||
}
|
||||
|
||||
// Otherwise do a plain read
|
||||
// Otherwise do a plain read, prioritizing readUrl if available
|
||||
if (this.options.reader.readUrl) {
|
||||
const data = await this.options.reader.readUrl(location);
|
||||
return [{ url: location, data: await data.buffer() }];
|
||||
}
|
||||
|
||||
const data = await this.options.reader.read(location);
|
||||
return [{ url: location, data }];
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export async function readCodeOwners(
|
||||
): Promise<string | undefined> {
|
||||
const readOwnerLocation = async (path: string): Promise<string> => {
|
||||
const url = `${sourceUrl}${path}`;
|
||||
|
||||
if (reader.readUrl) {
|
||||
const data = await reader.readUrl(url);
|
||||
const buffer = await data.buffer();
|
||||
return buffer.toString();
|
||||
}
|
||||
const data = await reader.read(url);
|
||||
return data.toString();
|
||||
};
|
||||
|
||||
@@ -46,29 +46,19 @@ jest.doMock('@octokit/rest', () => {
|
||||
return { Octokit };
|
||||
});
|
||||
|
||||
// Mock the value to control which integrations are activated
|
||||
jest.mock('./GitHub', () => ({
|
||||
getGithubIntegrationConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { CatalogImportClient } from './CatalogImportClient';
|
||||
import { getGithubIntegrationConfig } from './GitHub';
|
||||
|
||||
import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { OAuthApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
describe('CatalogImportClient', () => {
|
||||
const server = setupServer();
|
||||
msw.setupDefaultHandlers(server);
|
||||
|
||||
const mockBaseUrl = 'http://backstage:9191/api/catalog';
|
||||
@@ -92,7 +82,19 @@ describe('CatalogImportClient', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const scmIntegrationsApi = ScmIntegrations.fromConfig(new ConfigReader({}));
|
||||
const scmIntegrationsApi = ScmIntegrations.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [{ host: 'example.com' }],
|
||||
gitlab: [
|
||||
{
|
||||
host: 'registered-but-not-github.com',
|
||||
apiBaseUrl: 'https://registered-but-not-github.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
getEntities: jest.fn(),
|
||||
@@ -106,7 +108,6 @@ describe('CatalogImportClient', () => {
|
||||
};
|
||||
|
||||
let catalogImportClient: CatalogImportClient;
|
||||
let getGithubIntegrationConfigFn: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
catalogImportClient = new CatalogImportClient({
|
||||
@@ -116,9 +117,6 @@ describe('CatalogImportClient', () => {
|
||||
identityApi,
|
||||
catalogApi,
|
||||
});
|
||||
|
||||
getGithubIntegrationConfigFn = getGithubIntegrationConfig as jest.Mock;
|
||||
getGithubIntegrationConfigFn.mockReset();
|
||||
});
|
||||
|
||||
describe('analyzeUrl', () => {
|
||||
@@ -169,10 +167,22 @@ describe('CatalogImportClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore missing github integration', async () => {
|
||||
it('should reject for integrations that are not github ones', async () => {
|
||||
await expect(
|
||||
catalogImportClient.analyzeUrl(
|
||||
'https://github.com/backstage/backstage',
|
||||
'https://registered-but-not-github.com/backstage/backstage',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new Error(
|
||||
'The registered-but-not-github.com integration only supports full URLs to catalog-info.yaml files. Did you try to pass in the URL of a directory instead?',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject when unable to match with any integration', async () => {
|
||||
await expect(
|
||||
catalogImportClient.analyzeUrl(
|
||||
'https://not-registered-as-integration.com/foo/bar',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new Error(
|
||||
@@ -182,12 +192,6 @@ describe('CatalogImportClient', () => {
|
||||
});
|
||||
|
||||
it('should find locations from github', async () => {
|
||||
getGithubIntegrationConfigFn.mockReturnValue({
|
||||
repo: 'backstage',
|
||||
owner: 'backstage',
|
||||
githubIntegrationConfig: {} as GitHubIntegrationConfig,
|
||||
});
|
||||
|
||||
((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({
|
||||
data: {
|
||||
total_count: 2,
|
||||
@@ -244,12 +248,6 @@ describe('CatalogImportClient', () => {
|
||||
});
|
||||
|
||||
it('should find repository from github', async () => {
|
||||
getGithubIntegrationConfigFn.mockReturnValue({
|
||||
repo: 'backstage',
|
||||
owner: 'backstage',
|
||||
githubIntegrationConfig: {} as GitHubIntegrationConfig,
|
||||
});
|
||||
|
||||
((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({
|
||||
data: { total_count: 0, items: [] },
|
||||
});
|
||||
@@ -300,14 +298,6 @@ describe('CatalogImportClient', () => {
|
||||
|
||||
describe('submitPullRequest', () => {
|
||||
it('should create GitHub pull request', async () => {
|
||||
getGithubIntegrationConfigFn.mockReturnValue({
|
||||
repo: 'backstage',
|
||||
owner: 'backstage',
|
||||
githubIntegrationConfig: {
|
||||
host: 'github.com',
|
||||
} as GitHubIntegrationConfig,
|
||||
});
|
||||
|
||||
await expect(
|
||||
catalogImportClient.submitPullRequest({
|
||||
repositoryUrl: 'https://github.com/backstage/backstage',
|
||||
|
||||
@@ -77,6 +77,12 @@ export class CatalogImportClient implements CatalogImportApi {
|
||||
|
||||
const ghConfig = getGithubIntegrationConfig(this.scmIntegrationsApi, url);
|
||||
if (!ghConfig) {
|
||||
const other = this.scmIntegrationsApi.byUrl(url);
|
||||
if (other) {
|
||||
throw new Error(
|
||||
`The ${other.title} integration only supports full URLs to catalog-info.yaml files. Did you try to pass in the URL of a directory instead?`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a catalog-info.yaml file instead.',
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import {
|
||||
EntityKindPicker,
|
||||
EntityLifecyclePicker,
|
||||
@@ -39,18 +39,6 @@ import {
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'table'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
buttonSpacing: {
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export type CatalogPageProps = {
|
||||
initiallySelectedFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<EntityRow>[];
|
||||
@@ -61,30 +49,36 @@ export const CatalogPage = ({
|
||||
initiallySelectedFilter = 'owned',
|
||||
columns,
|
||||
actions,
|
||||
}: CatalogPageProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<CatalogLayout>
|
||||
<Content>
|
||||
<ContentHeader title="Components">
|
||||
<CreateComponentButton />
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<div className={styles.contentWrapper}>
|
||||
<EntityListProvider>
|
||||
<div>
|
||||
<EntityKindPicker initialFilter="component" hidden />
|
||||
<EntityTypePicker />
|
||||
<UserListPicker initialFilter={initiallySelectedFilter} />
|
||||
<EntityOwnerPicker />
|
||||
<EntityLifecyclePicker />
|
||||
<EntityTagPicker />
|
||||
</div>
|
||||
}: CatalogPageProps) => (
|
||||
<CatalogLayout>
|
||||
<Content>
|
||||
<ContentHeader title="Components">
|
||||
<CreateComponentButton />
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={2}>
|
||||
<EntityListProvider>
|
||||
<Grid item sm={12} lg={2} alignContent="flex-start">
|
||||
<Grid container>
|
||||
<Grid item xs={12} sm={4} lg={12}>
|
||||
<EntityKindPicker initialFilter="component" hidden />
|
||||
<EntityTypePicker />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4} lg={12}>
|
||||
<UserListPicker initialFilter={initiallySelectedFilter} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4} lg={12}>
|
||||
<EntityOwnerPicker />
|
||||
<EntityLifecyclePicker />
|
||||
<EntityTagPicker />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={12} lg={10}>
|
||||
<CatalogTable columns={columns} actions={actions} />
|
||||
</EntityListProvider>
|
||||
</div>
|
||||
</Content>
|
||||
</CatalogLayout>
|
||||
);
|
||||
};
|
||||
</Grid>
|
||||
</EntityListProvider>
|
||||
</Grid>
|
||||
</Content>
|
||||
</CatalogLayout>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ExploreTool } from '@backstage/plugin-explore-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { Button } from '@backstage/core-components';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ToolCard = ({ card, objectFit }: Props) => {
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button color="primary" href={url} disabled={!url}>
|
||||
<Button color="primary" to={url} disabled={!url}>
|
||||
Explore
|
||||
</Button>
|
||||
</CardActions>
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('Features', () => {
|
||||
<a
|
||||
class="MuiTypography-root MuiLink-root MuiLink-underlineHover MuiTypography-colorPrimary"
|
||||
href="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
to="https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository"
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@backstage/plugin-catalog-react": "^0.2.4",
|
||||
"@backstage/theme": "^0.2.8",
|
||||
"@date-io/luxon": "1.x",
|
||||
"@date-io/luxon": "2.x",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"fs-extra": "^9.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"helmet": "^4.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"morgan": "^1.10.0",
|
||||
|
||||
@@ -23,23 +23,30 @@ import {
|
||||
EntityRefLinks,
|
||||
getEntityRelations,
|
||||
useEntity,
|
||||
getEntityMetadataEditUrl,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import AccountTreeIcon from '@material-ui/icons/AccountTree';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import React from 'react';
|
||||
import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components';
|
||||
import {
|
||||
Avatar,
|
||||
InfoCard,
|
||||
InfoCardVariants,
|
||||
Link,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const CardTitle = ({ title }: { title: string }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
@@ -72,14 +79,31 @@ export const GroupProfileCard = ({
|
||||
kind: 'group',
|
||||
});
|
||||
|
||||
const entityMetadataEditUrl = getEntityMetadataEditUrl(group);
|
||||
|
||||
const displayName = profile?.displayName ?? name;
|
||||
const emailHref = profile?.email ? `mailto:${profile.email}` : undefined;
|
||||
const emailHref = profile?.email ? `mailto:${profile.email}` : '#';
|
||||
const infoCardAction = entityMetadataEditUrl ? (
|
||||
<IconButton
|
||||
aria-label="Edit"
|
||||
title="Edit Metadata"
|
||||
component={Link}
|
||||
to={entityMetadataEditUrl}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton aria-label="Edit" disabled title="Edit Metadata">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={<CardTitle title={displayName} />}
|
||||
subheader={description}
|
||||
variant={variant}
|
||||
action={infoCardAction}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={2} xl={1}>
|
||||
@@ -95,7 +119,7 @@ export const GroupProfileCard = ({
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<Link href={emailHref}>{profile.email}</Link>
|
||||
<Link to={emailHref}>{profile.email}</Link>
|
||||
</ListItemText>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,21 @@ There is also an easy way to trigger an alarm directly to the person who is curr
|
||||
|
||||
This plugin requires that entities are annotated with an [integration key](https://support.pagerduty.com/docs/services-and-integrations#add-integrations-to-an-existing-service). See more further down in this document.
|
||||
|
||||
This plugin provides:
|
||||
## Features
|
||||
|
||||
- A list of incidents
|
||||
- A way to trigger an alarm to the person on-call
|
||||
- Information details about the person on-call
|
||||
### View any open incidents
|
||||
|
||||

|
||||
|
||||
### Email link, and view contact information for staff on call
|
||||
|
||||

|
||||
|
||||
### Trigger an incident for a service
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Setup instructions
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 49 KiB |