Merge branch 'master' into lintMod

This commit is contained in:
Fredrik Adelöw
2021-02-04 07:58:13 +01:00
50 changed files with 880 additions and 495 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': patch
---
Fixed date calculations incorrectly converting to UTC in some cases. This should be a transparent change.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Fix gitlab handling of paths with spaces
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend': minor
---
Make use of the `resolveUrl` facility of the `integration` package.
Also rename the `LocationRefProcessor` to `LocationEntityProcessor`, to match the file name. This constitutes an interface change since the class is exported, but it is unlikely to be consumed outside of the package since it sits comfortably with the other default processors inside the catalog builder.
+6
View File
@@ -0,0 +1,6 @@
---
'example-app': patch
'@backstage/plugin-rollbar': patch
---
Migrated to new composability API, exporting the plugin instance as `rollbarPlugin`, the entity page content as `EntityRollbarContent`, and entity conditional as `isRollbarAvailable`. Updated the `EntityPage` for the `example-app` to include a composite `ErrorsSwitcher` component that works with both `Sentry` & `Rollbar`. Also removed the unused and undocumented `RollbarHome` related components.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/integration': patch
---
Add a `resolveUrl` method to integrations, that works like the two-argument URL
constructor. The reason for using this is that Azure have their paths in a
query parameter, rather than the pathname of the URL.
The implementation is optional (when not present, the URL constructor is used),
so this does not imply a breaking change.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': major
---
#4322 Bitbucket own hosted v5.11.1 branchUrl fix and enabled error tracing… #4347
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': patch
---
Implement matchEntityWithRef for client side filtering of entities by ref matching
+1 -1
View File
@@ -41,7 +41,7 @@
},
"version": "1.0.0",
"devDependencies": {
"@changesets/cli": "^2.11.0",
"@changesets/cli": "^2.14.0",
"@octokit/openapi-types": "^2.2.0",
"@spotify/eslint-config-oss": "^1.0.1",
"@spotify/prettier-config": "^9.0.0",
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiEntity,
Entity,
@@ -64,6 +65,10 @@ import {
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from '@backstage/plugin-pagerduty';
import {
isRollbarAvailable,
Router as RollbarRouter,
} from '@backstage/plugin-rollbar';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Button, Grid } from '@material-ui/core';
@@ -153,6 +158,15 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
);
};
export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => {
switch (true) {
case isRollbarAvailable(entity):
return <RollbarRouter entity={entity} />;
default:
return <SentryRouter entity={entity} />;
}
};
const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
@@ -212,9 +226,9 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
element={<CICDSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
element={<SentryRouter entity={entity} />}
path="/errors/*"
title="Errors"
element={<ErrorsSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/api/*"
@@ -267,9 +281,9 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
element={<LighthouseRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/sentry"
title="Sentry"
element={<SentryRouter entity={entity} />}
path="/errors/*"
title="Errors"
element={<ErrorsSwitcher entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -76,6 +76,8 @@ function withRetries(count: number, fn: () => Promise<void>) {
}
describe('UrlReaders', () => {
jest.setTimeout(30_000);
it(
'should read data from azure',
withRetries(3, async () => {
@@ -27,6 +27,7 @@ export type {
} from './Entity';
export * from './policies';
export {
compareEntityToRef,
getEntityName,
parseEntityName,
parseEntityRef,
+322 -1
View File
@@ -16,7 +16,12 @@
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
import {
compareEntityToRef,
parseEntityName,
parseEntityRef,
serializeEntityRef,
} from './ref';
describe('ref', () => {
describe('parseEntityName', () => {
@@ -381,4 +386,320 @@ describe('ref', () => {
).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' });
});
});
describe('compareEntityToRef', () => {
const entityWithNamespace: Entity = {
apiVersion: 'a',
kind: 'K',
metadata: {
name: 'n',
namespace: 'ns',
},
};
const entityWithoutNamespace: Entity = {
apiVersion: 'a',
kind: 'K',
metadata: {
name: 'n',
},
};
it('handles matching string refs', () => {
expect(compareEntityToRef(entityWithNamespace, 'K:ns/n')).toBe(true);
expect(compareEntityToRef(entityWithNamespace, 'k:nS/N')).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'K:n', {
defaultNamespace: 'ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'K:n', {
defaultNamespace: 'Ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'ns/n', { defaultKind: 'K' }),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'n', {
defaultKind: 'K',
defaultNamespace: 'ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'N', {
defaultKind: 'k',
defaultNamespace: 'nS',
}),
).toBe(true);
expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
true,
);
expect(compareEntityToRef(entityWithoutNamespace, 'K:deFault/n')).toBe(
true,
);
expect(
compareEntityToRef(entityWithoutNamespace, 'K:n', {
defaultNamespace: 'default',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'K:n', {
defaultNamespace: 'deFault',
}),
).toBe(true);
expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
true,
);
expect(compareEntityToRef(entityWithoutNamespace, 'K:n')).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'default/n', {
defaultKind: 'K',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'n', {
defaultKind: 'K',
defaultNamespace: 'default',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, 'n', {
defaultKind: 'K',
}),
).toBe(true);
});
it('handles mismatching string refs', () => {
expect(compareEntityToRef(entityWithNamespace, 'X:ns/n')).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, 'ns/n', {
defaultKind: 'X',
}),
).toBe(false);
expect(compareEntityToRef(entityWithNamespace, 'K:xx/n')).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, 'K:n', {
defaultNamespace: 'xx',
}),
).toBe(false);
expect(compareEntityToRef(entityWithNamespace, 'K:ns/x')).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, 'x', {
defaultKind: 'K',
defaultNamespace: 'ns',
}),
).toBe(false);
});
it('handles matching compound refs', () => {
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'K',
namespace: 'ns',
name: 'n',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'k',
namespace: 'Ns',
name: 'N',
}),
).toBe(true);
expect(
compareEntityToRef(
entityWithNamespace,
{ kind: 'K', name: 'n' },
{
defaultNamespace: 'ns',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithNamespace,
{ namespace: 'ns', name: 'n' },
{ defaultKind: 'K' },
),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'n', {
defaultKind: 'K',
defaultNamespace: 'ns',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithNamespace, 'N', {
defaultKind: 'k',
defaultNamespace: 'nS',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'K',
namespace: 'default',
name: 'n',
}),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'k',
namespace: 'deFault',
name: 'N',
}),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ kind: 'K', name: 'n' },
{
defaultNamespace: 'default',
},
),
).toBe(true);
expect(
compareEntityToRef(entityWithoutNamespace, { kind: 'K', name: 'n' }),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ namespace: 'default', name: 'n' },
{
defaultKind: 'K',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ name: 'n' },
{
defaultKind: 'K',
defaultNamespace: 'default',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ name: 'N' },
{
defaultKind: 'k',
defaultNamespace: 'defAult',
},
),
).toBe(true);
expect(
compareEntityToRef(
entityWithoutNamespace,
{ name: 'n' },
{
defaultKind: 'K',
},
),
).toBe(true);
});
it('handles mismatching compound refs', () => {
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'X',
namespace: 'ns',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithNamespace,
{
namespace: 'ns',
name: 'n',
},
{ defaultKind: 'X' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'X',
namespace: 'default',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithoutNamespace,
{
namespace: 'default',
name: 'n',
},
{ defaultKind: 'X' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'K',
namespace: 'xx',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithNamespace,
{
kind: 'K',
name: 'n',
},
{ defaultNamespace: 'xx' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'K',
namespace: 'xx',
name: 'n',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithoutNamespace,
{
kind: 'K',
name: 'n',
},
{ defaultNamespace: 'xx' },
),
).toBe(false);
expect(
compareEntityToRef(entityWithNamespace, {
kind: 'K',
namespace: 'ns',
name: 'x',
}),
).toBe(false);
expect(
compareEntityToRef(entityWithoutNamespace, {
kind: 'K',
namespace: 'default',
name: 'x',
}),
).toBe(false);
expect(
compareEntityToRef(
entityWithoutNamespace,
{
kind: 'K',
name: 'x',
},
{ defaultNamespace: 'default' },
),
).toBe(false);
});
});
});
+75 -10
View File
@@ -18,6 +18,27 @@ import { EntityName, EntityRef } from '../types';
import { ENTITY_DEFAULT_NAMESPACE } from './constants';
import { Entity } from './Entity';
function parseRefString(
ref: string,
): {
kind?: string;
namespace?: string;
name: string;
} {
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
if (!match) {
throw new TypeError(
`Entity reference "${ref}" was not on the form [<kind>:][<namespace>/]<name>`,
);
}
return {
kind: match[1]?.slice(0, -1),
namespace: match[2]?.slice(0, -1),
name: match[3],
};
}
/**
* Extracts the kind, namespace and name that form the name triplet of the
* given entity.
@@ -121,17 +142,11 @@ export function parseEntityRef(
}
if (typeof ref === 'string') {
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
if (!match) {
throw new Error(
`Entity reference "${ref}" was not on the form [<kind>:][<namespace>/]<name>`,
);
}
const parsed = parseRefString(ref);
return {
kind: match[1]?.slice(0, -1) ?? context.defaultKind,
namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace,
name: match[3],
kind: parsed.kind ?? context.defaultKind,
namespace: parsed.namespace ?? context.defaultNamespace,
name: parsed.name,
};
}
@@ -196,3 +211,53 @@ export function serializeEntityRef(
return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`;
}
/**
* Compares an entity to either a string reference or a compound reference.
*
* The comparison is case insensitive, and all of kind, namespace, and name
* must match (after applying the optional context to the ref).
*
* @param entity The entity to match
* @param ref A string or compound entity ref
* @param context An optional context of default kind and namespace, that apply
* to the ref if given
* @returns True if matching, false otherwise
*/
export function compareEntityToRef(
entity: Entity,
ref: EntityRef | EntityName,
context?: EntityRefContext,
): boolean {
const entityKind = entity.kind;
const entityNamespace = entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE;
const entityName = entity.metadata.name;
let refKind: string | undefined;
let refNamespace: string | undefined;
let refName: string;
if (typeof ref === 'string') {
const parsed = parseRefString(ref);
refKind = parsed.kind || context?.defaultKind;
refNamespace =
parsed.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
refName = parsed.name;
} else {
refKind = ref.kind || context?.defaultKind;
refNamespace =
ref.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
refName = ref.name;
}
if (!refKind || !refNamespace) {
throw new Error(
`Entity reference or context did not contain kind and namespace`,
);
}
return (
entityKind.toLowerCase() === refKind.toLowerCase() &&
entityNamespace.toLowerCase() === refNamespace.toLowerCase() &&
entityName.toLowerCase() === refName.toLowerCase()
);
}
@@ -73,4 +73,19 @@ describe('ScmIntegrations', () => {
expect(i.byHost('github.local')).toBe(github);
expect(i.byHost('gitlab.local')).toBe(gitlab);
});
it('can resolveUrl using fallback', () => {
expect(
i.resolveUrl({
url: '../b.yaml',
base: 'https://no-matching-integration.com/x/a.yaml',
}),
).toBe('https://no-matching-integration.com/b.yaml');
expect(
i.resolveUrl({
url: 'https://absolute.com/path',
base: 'https://no-matching-integration.com/x/a.yaml',
}),
).toBe('https://absolute.com/path');
});
});
@@ -81,4 +81,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
.map(i => i.byHost(host))
.find(Boolean);
}
resolveUrl(options: { url: string; base: string }): string {
const resolve = this.byUrl(options.base)?.resolveUrl;
if (!resolve) {
return new URL(options.url, options.base).toString();
}
return resolve(options);
}
}
@@ -41,4 +41,52 @@ describe('AzureIntegration', () => {
expect(integration.type).toBe('azure');
expect(integration.title).toBe('h.com');
});
describe('resolveUrl', () => {
it('works for valid urls', () => {
const integration = new AzureIntegration({
host: 'dev.azure.com',
} as any);
expect(
integration.resolveUrl({
url: '../a.yaml',
base:
'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml',
}),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
expect(
integration.resolveUrl({
url: './a.yaml',
base: 'https://dev.azure.com/organization/project/_git/repository',
}),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
expect(
integration.resolveUrl({
url: 'https://absolute.com/path',
base:
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
}),
).toBe('https://absolute.com/path');
});
it('falls back to regular URL resolution if not in a repo', () => {
const integration = new AzureIntegration({
host: 'dev.azure.com',
} as any);
expect(
integration.resolveUrl({
url: './test',
base: 'https://dev.azure.com/organization/project/_git',
}),
).toBe('https://dev.azure.com/organization/project/test');
});
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { basicIntegrations } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config';
@@ -42,4 +43,39 @@ export class AzureIntegration implements ScmIntegration {
get config(): AzureIntegrationConfig {
return this.integrationConfig;
}
/*
* Azure repo URLs on the form with a `path` query param are treated specially.
*
* Example base URL: https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml
*/
resolveUrl(options: { url: string; base: string }): string {
const { url, base } = options;
// If we can parse the url, it is absolute - then return it verbatim
try {
// eslint-disable-next-line no-new
new URL(url);
return url;
} catch {
// Ignore intentionally - looks like a relative path
}
const parsed = parseGitUrl(base);
const { organization, owner, name, filepath } = parsed;
// If not an actual file path within a repo, treat the URL as raw
if (!organization || !owner || !name) {
return new URL(url, base).toString();
}
const path = filepath?.replace(/^\//, '') || '';
const mockBaseUrl = new URL(`https://a.com/${path}`);
const updatedPath = new URL(url, mockBaseUrl).pathname;
const newUrl = new URL(base);
newUrl.searchParams.set('path', updatedPath);
return newUrl.toString();
}
}
@@ -125,6 +125,7 @@ describe('bitbucket core', () => {
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
@@ -250,5 +251,40 @@ describe('bitbucket core', () => {
);
expect(defaultBranch).toEqual('main');
});
it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => {
const defaultBranchResponse = {
displayId: 'main',
};
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch',
(_, res, ctx) =>
res(
ctx.status(404),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(defaultBranchResponse),
),
),
);
const config: BitbucketIntegrationConfig = {
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
};
const defaultBranch = await getBitbucketDefaultBranch(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md',
config,
);
expect(defaultBranch).toEqual('main');
});
});
});
+10 -2
View File
@@ -32,11 +32,19 @@ export async function getBitbucketDefaultBranch(
const isHosted = resource === 'bitbucket.org';
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184
const branchUrl = isHosted
let branchUrl = isHosted
? `${config.apiBaseUrl}/repositories/${project}/${repoName}`
: `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`;
const response = await fetch(branchUrl, getBitbucketRequestOptions(config));
let response = await fetch(branchUrl, getBitbucketRequestOptions(config));
if (response.status === 404 && !isHosted) {
// First try the new format, and then if it gets specifically a 404 it should try the old format
// (to support old Atlassian Bitbucket v5.11.1 format )
branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`;
response = await fetch(branchUrl, getBitbucketRequestOptions(config));
}
if (!response.ok) {
const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`;
throw new Error(message);
+6 -6
View File
@@ -49,23 +49,23 @@ describe('gitlab core', () => {
{
config: configWithNoToken,
url:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml',
result:
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch',
},
{
config: configWithToken,
url:
'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml',
result:
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch',
},
{
config: configWithNoToken,
url:
'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup
'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', // Repo not in subgroup
result:
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch',
},
// Raw URLs
{
+1 -1
View File
@@ -109,7 +109,7 @@ export function buildProjectUrl(target: string, projectID: Number): URL {
'/api/v4/projects',
projectID,
'repository/files',
encodeURIComponent(filePath.join('/')),
encodeURIComponent(decodeURIComponent(filePath.join('/'))),
'raw',
].join('/');
url.search = `?ref=${branch}`;
+21
View File
@@ -34,6 +34,18 @@ export interface ScmIntegration {
* differentiate between different integrations.
*/
title: string;
/**
* Works like the two-argument form of the URL constructor, resolving an
* absolute or relative URL in relation to a base URL.
*
* If this method is not implemented, the URL constructor is used instead for
* URLs that match this integration.
*
* @param options.url The (absolute or relative) URL or path to resolve
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl?(options: { url: string; base: string }): string;
}
/**
@@ -69,6 +81,15 @@ export interface ScmIntegrationRegistry
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
github: ScmIntegrationsGroup<GitHubIntegration>;
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
/**
* Works like the two-argument form of the URL constructor, resolving an
* absolute or relative URL in relation to a base URL.
*
* @param options.url The (absolute or relative) URL or path to resolve
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl(options: { url: string; base: string }): string;
}
export type ScmIntegrationsFactory<T extends ScmIntegration> = (options: {
+1
View File
@@ -34,6 +34,7 @@
"@backstage/backend-common": "^0.5.1",
"@backstage/catalog-model": "^0.7.0",
"@backstage/config": "^0.1.2",
"@backstage/integration": "^0.3.1",
"@octokit/graphql": "^4.5.8",
"@types/express": "^4.17.6",
"@types/ldapjs": "^1.0.9",
@@ -15,30 +15,73 @@
*/
import { LocationSpec } from '@backstage/catalog-model';
import { toAbsoluteUrl } from './LocationEntityProcessor';
import { ConfigReader } from '@backstage/config';
import {
ScmIntegrations,
ScmIntegrationRegistry,
} from '@backstage/integration';
import path from 'path';
import { toAbsoluteUrl } from './LocationEntityProcessor';
describe('LocationEntityProcessor', () => {
describe('toAbsoluteUrl', () => {
it('handles files', () => {
const integrations = ({} as unknown) as ScmIntegrationRegistry;
const base: LocationSpec = {
type: 'file',
target: `some${path.sep}path${path.sep}catalog-info.yaml`,
};
expect(toAbsoluteUrl(base, `.${path.sep}c`)).toBe(
expect(toAbsoluteUrl(integrations, base, `.${path.sep}c`)).toBe(
`some${path.sep}path${path.sep}c`,
);
expect(toAbsoluteUrl(base, `${path.sep}c`)).toBe(`${path.sep}c`);
expect(toAbsoluteUrl(integrations, base, `${path.sep}c`)).toBe(
`${path.sep}c`,
);
});
it('handles urls', () => {
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const base: LocationSpec = {
type: 'url',
target: 'http://a.com/b/catalog-info.yaml',
};
expect(toAbsoluteUrl(base, './c/d')).toBe('http://a.com/b/c/d');
expect(toAbsoluteUrl(base, 'c/d')).toBe('http://a.com/b/c/d');
expect(toAbsoluteUrl(base, 'http://b.com/z')).toBe('http://b.com/z');
jest.spyOn(integrations, 'resolveUrl');
expect(toAbsoluteUrl(integrations, base, './c/d')).toBe(
'http://a.com/b/c/d',
);
expect(toAbsoluteUrl(integrations, base, 'c/d')).toBe(
'http://a.com/b/c/d',
);
expect(toAbsoluteUrl(integrations, base, 'http://b.com/z')).toBe(
'http://b.com/z',
);
expect(integrations.resolveUrl).toBeCalledTimes(3);
});
it('handles azure urls specifically', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [{ host: 'dev.azure.com' }],
},
}),
);
expect(
toAbsoluteUrl(
integrations,
{
type: 'url',
target:
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
},
'./a.yaml',
),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
});
});
});
@@ -15,11 +15,16 @@
*/
import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model';
import { ScmIntegrationRegistry } from '@backstage/integration';
import path from 'path';
import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
import path from 'path';
export function toAbsoluteUrl(base: LocationSpec, target: string): string {
export function toAbsoluteUrl(
integrations: ScmIntegrationRegistry,
base: LocationSpec,
target: string,
): string {
try {
if (base.type === 'file') {
if (target.startsWith('.')) {
@@ -27,13 +32,19 @@ export function toAbsoluteUrl(base: LocationSpec, target: string): string {
}
return target;
}
return new URL(target, base.target).toString();
return integrations.resolveUrl({ url: target, base: base.target });
} catch (e) {
return target;
}
}
export class LocationRefProcessor implements CatalogProcessor {
type Options = {
integrations: ScmIntegrationRegistry;
};
export class LocationEntityProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async postProcessEntity(
entity: Entity,
location: LocationSpec,
@@ -47,7 +58,7 @@ export class LocationRefProcessor implements CatalogProcessor {
emit(
result.inputError(
location,
`LocationRefProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`,
`LocationEntityProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`,
),
);
}
@@ -61,7 +72,11 @@ export class LocationRefProcessor implements CatalogProcessor {
}
for (const maybeRelativeTarget of targets) {
const target = toAbsoluteUrl(location, maybeRelativeTarget);
const target = toAbsoluteUrl(
this.options.integrations,
location,
maybeRelativeTarget,
);
emit(result.location({ type, target }, false));
}
}
@@ -23,7 +23,7 @@ export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor';
export { LocationRefProcessor } from './LocationEntityProcessor';
export { LocationEntityProcessor } from './LocationEntityProcessor';
export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor';
export { PlaceholderProcessor } from './PlaceholderProcessor';
export type { PlaceholderResolver } from './PlaceholderProcessor';
@@ -26,6 +26,7 @@ import {
Validators,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import lodash from 'lodash';
import { Logger } from 'winston';
import {
@@ -46,8 +47,8 @@ import {
HigherOrderOperation,
HigherOrderOperations,
LdapOrgReaderProcessor,
LocationEntityProcessor,
LocationReaders,
LocationRefProcessor,
MicrosoftGraphOrgReaderProcessor,
PlaceholderProcessor,
PlaceholderResolver,
@@ -280,6 +281,7 @@ export class CatalogBuilder {
private buildProcessors(): CatalogProcessor[] {
const { config, logger, reader } = this.env;
const integrations = ScmIntegrations.fromConfig(config);
this.checkDeprecatedReaderProcessors();
@@ -306,7 +308,7 @@ export class CatalogBuilder {
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader, logger }),
new LocationRefProcessor(),
new LocationEntityProcessor({ integrations }),
new AnnotateLocationEntityProcessor(),
);
}
@@ -76,13 +76,13 @@ describe('getPreviousPeriodTotalCost', () => {
change: changeOf(MockAggregatedDailyCosts),
trendline: trendlineOf(MockAggregatedDailyCosts),
};
const exclusiveEndDate = '2020-09-30';
const inclusiveEndDate = '2020-09-30';
expect(
getPreviousPeriodTotalCost(
mockGroupDailyCost.aggregation,
Duration.P30D,
exclusiveEndDate,
inclusiveEndDate,
),
).toEqual(100_000);
).toEqual(96_600);
});
});
+1 -5
View File
@@ -22,7 +22,6 @@ import {
GrowthType,
MetricData,
Duration,
DEFAULT_DATE_FORMAT,
DateAggregation,
} from '../types';
import dayjs, { OpUnitType } from 'dayjs';
@@ -73,10 +72,7 @@ export function getPreviousPeriodTotalCost(
inclusiveEndDate: string,
): number {
const dayjsDuration = dayjs.duration(duration);
const startDate = inclusiveStartDateOf(
duration,
dayjs(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT),
);
const startDate = inclusiveStartDateOf(duration, inclusiveEndDate);
// dayjs doesn't allow adding an ISO 8601 period to dates.
const [amount, type]: [number, OpUnitType] = dayjsDuration.days()
? [dayjsDuration.days(), 'day']
+6 -13
View File
@@ -24,23 +24,21 @@ export const DEFAULT_DURATION = Duration.P30D;
* Derive the start date of a given period, assuming two repeating intervals.
*
* @param duration see comment on Duration enum
* @param exclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate + 1 day
* @param inclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate
*/
export function inclusiveStartDateOf(
duration: Duration,
exclusiveEndDate: string,
inclusiveEndDate: string,
): string {
switch (duration) {
case Duration.P7D:
case Duration.P30D:
case Duration.P90D:
return moment(exclusiveEndDate)
.utc()
return moment(inclusiveEndDate)
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(exclusiveEndDate)
.utc()
return moment(inclusiveEndDate)
.startOf('quarter')
.subtract(moment.duration(duration).add(moment.duration(duration)))
.format(DEFAULT_DATE_FORMAT);
@@ -57,13 +55,9 @@ export function exclusiveEndDateOf(
case Duration.P7D:
case Duration.P30D:
case Duration.P90D:
return moment(inclusiveEndDate)
.utc()
.add(1, 'day')
.format(DEFAULT_DATE_FORMAT);
return moment(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT);
case Duration.P3M:
return moment(quarterEndDate(inclusiveEndDate))
.utc()
.add(1, 'day')
.format(DEFAULT_DATE_FORMAT);
default:
@@ -76,7 +70,6 @@ export function inclusiveEndDateOf(
inclusiveEndDate: string,
): string {
return moment(exclusiveEndDateOf(duration, inclusiveEndDate))
.utc()
.subtract(1, 'day')
.format(DEFAULT_DATE_FORMAT);
}
@@ -94,7 +87,7 @@ export function intervalsOf(
}
export function quarterEndDate(inclusiveEndDate: string): string {
const endDate = moment(inclusiveEndDate).utc();
const endDate = moment(inclusiveEndDate);
const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT);
if (endOfQuarter === inclusiveEndDate) {
return endDate.format(DEFAULT_DATE_FORMAT);
@@ -16,7 +16,7 @@
import moment from 'moment';
import pluralize from 'pluralize';
import { Duration, DEFAULT_DATE_FORMAT } from '../types';
import { Duration } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
export type Period = {
@@ -92,11 +92,8 @@ export function formatPercent(n: number): string {
}
export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) {
const exclusiveEndDate = moment(inclusiveEndDate)
.add(1, 'day')
.format(DEFAULT_DATE_FORMAT);
const start = moment(
inclusiveStartDateOf(Duration.P3M, exclusiveEndDate),
inclusiveStartDateOf(Duration.P3M, inclusiveEndDate),
).format('[Q]Q YYYY');
const end = moment(inclusiveEndDateOf(Duration.P3M, inclusiveEndDate)).format(
'[Q]Q YYYY',
+4 -3
View File
@@ -36,7 +36,7 @@ import {
getDefaultState as getDefaultLoadingState,
} from '../utils/loading';
import { findAlways } from '../utils/assert';
import { inclusiveStartDateOf } from './duration';
import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration';
type mockAlertRenderer<T> = (alert: T) => T;
type mockEntityRenderer<T> = (entity: T) => T;
@@ -228,8 +228,9 @@ export function aggregationFor(
baseline: number,
): DateAggregation[] {
const { duration, endDate } = parseIntervals(intervals);
const inclusiveEndDate = inclusiveEndDateOf(duration, endDate);
const days = dayjs(endDate).diff(
inclusiveStartDateOf(duration, endDate),
inclusiveStartDateOf(duration, inclusiveEndDate),
'day',
);
@@ -244,7 +245,7 @@ export function aggregationFor(
return [...Array(days).keys()].reduce(
(values: DateAggregation[], i: number): DateAggregation[] => {
const last = values.length ? values[values.length - 1].amount : baseline;
const date = dayjs(inclusiveStartDateOf(duration, endDate))
const date = dayjs(inclusiveStartDateOf(duration, inclusiveEndDate))
.add(i, 'day')
.format(DEFAULT_DATE_FORMAT);
const amount = Math.max(0, last + nextDelta());
+4 -14
View File
@@ -19,17 +19,7 @@ yarn add @backstage/plugin-rollbar
export { plugin as Rollbar } from '@backstage/plugin-rollbar';
```
4. Add plugin API to your Backstage instance:
```ts
// packages/app/src/api.ts
import { RollbarClient, rollbarApiRef } from '@backstage/plugin-rollbar';
// ...
builder.add(rollbarApiRef, new RollbarClient({ discoveryApi }));
```
5. Add to the app `EntityPage` component:
4. Add to the app `EntityPage` component:
```ts
// packages/app/src/components/catalog/EntityPage.tsx
@@ -48,7 +38,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
);
```
6. Setup the `app.config.yaml` and account token environment variable
5. Setup the `app.config.yaml` and account token environment variable
```yaml
# app.config.yaml
@@ -59,7 +49,7 @@ rollbar:
$env: ROLLBAR_ACCOUNT_TOKEN
```
7. Annotate entities with the rollbar project slug
6. Annotate entities with the rollbar project slug
```yaml
# pump-station-catalog-component.yaml
@@ -71,7 +61,7 @@ metadata:
rollbar.com/project-slug: project-name
```
8. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity
7. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity
## Features
+2 -2
View File
@@ -15,6 +15,6 @@
*/
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
import { rollbarPlugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
createDevApp().registerPlugin(rollbarPlugin).render();
@@ -14,14 +14,18 @@
* limitations under the License.
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import React from 'react';
import { RollbarProject } from '../RollbarProject/RollbarProject';
type Props = {
entity: Entity;
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
};
export const EntityPageRollbar = ({ entity }: Props) => {
export const EntityPageRollbar = (_props: Props) => {
const { entity } = useEntity();
return <RollbarProject entity={entity} />;
};
@@ -1,70 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import {
ApiProvider,
ApiRegistry,
ConfigApi,
configApiRef,
} from '@backstage/core';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi';
import { RollbarProject } from '../../api/types';
import { RollbarHome } from './RollbarHome';
describe('RollbarHome component', () => {
const projects: RollbarProject[] = [
{ id: 123, name: 'abc', accountId: 1, status: 'enabled' },
{ id: 456, name: 'xyz', accountId: 1, status: 'enabled' },
];
const rollbarApi: Partial<RollbarApi> = {
getAllProjects: () => Promise.resolve(projects),
};
const config: Partial<ConfigApi> = {
getString: () => 'foo',
getOptionalString: () => 'foo',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[rollbarApiRef, rollbarApi],
[configApiRef, config],
[
catalogApiRef,
({
async getEntities() {
return { items: [] };
},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
{children}
</ApiProvider>,
),
);
it('should render rollbar landing page', async () => {
const rendered = renderWrapped(<RollbarHome />);
expect(rendered.getByText(/Rollbar/)).toBeInTheDocument();
});
});
@@ -1,40 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Content, Header, Page } from '@backstage/core';
import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable';
import { useRollbarEntities } from '../../hooks/useRollbarEntities';
export const RollbarHome = () => {
const { entities, loading, error } = useRollbarEntities();
return (
<Page themeId="tool">
<Header
title="Rollbar"
subtitle="Real-time error tracking & debugging tools for developers"
/>
<Content>
<RollbarProjectTable
entities={entities || []}
loading={loading}
error={error}
/>
</Content>
</Page>
);
};
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import React from 'react';
import { useTopActiveItems } from '../../hooks/useTopActiveItems';
import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable';
@@ -1,86 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiProvider,
ApiRegistry,
ConfigApi,
configApiRef,
} from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi';
import { RollbarTopActiveItem } from '../../api/types';
import { RollbarProjectPage } from './RollbarProjectPage';
describe('RollbarProjectPage component', () => {
const items: RollbarTopActiveItem[] = [
{
item: {
id: 9898989,
counter: 1234,
environment: 'production',
framework: 2,
lastOccurrenceTimestamp: new Date().getTime() / 1000,
level: 50,
occurrences: 100,
projectId: 12345,
title: 'error occurred',
uniqueOccurrences: 10,
},
counts: [10, 10, 10, 10, 10, 50],
},
];
const rollbarApi: Partial<RollbarApi> = {
getTopActiveItems: () => Promise.resolve(items),
};
const config: Partial<ConfigApi> = {
getString: () => 'foo',
getOptionalString: () => 'foo',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[rollbarApiRef, rollbarApi],
[configApiRef, config],
[
catalogApiRef,
({
async getEntityByName() {
return {
metadata: { name: 'foo' },
spec: { owner: 'bar', lifecycle: 'experimental' },
} as any;
},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
{children}
</ApiProvider>,
),
);
it('should render rollbar project page', async () => {
const rendered = renderWrapped(<RollbarProjectPage />);
expect(rendered.getByText(/Rollbar/)).toBeInTheDocument();
});
});
@@ -1,36 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Content, Header, HeaderLabel, Page } from '@backstage/core';
import { useCatalogEntity } from '../../hooks/useCatalogEntity';
import { RollbarProject } from '../RollbarProject/RollbarProject';
export const RollbarProjectPage = () => {
const { entity } = useCatalogEntity();
return (
<Page themeId="tool">
<Header title={entity?.metadata?.name} subtitle="Rollbar Project">
<HeaderLabel label="Owner" value={entity?.spec?.owner} />
<HeaderLabel label="Lifecycle" value={entity?.spec?.lifecycle} />
</Header>
<Content>
{entity ? <RollbarProject entity={entity} /> : 'Loading'}
</Content>
</Page>
);
};
@@ -1,85 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { Table, TableColumn } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { entityRouteRef } from '../../routes';
const columns: TableColumn[] = [
{
title: 'Name',
field: 'metadata.name',
type: 'string',
highlight: true,
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRouteRef.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
})}
>
{entity.metadata.name}
</Link>
),
},
{
title: 'Description',
field: 'metadata.description',
},
];
type Props = {
entities: Entity[];
loading: boolean;
error?: any;
};
export const RollbarProjectTable = ({ entities, loading, error }: Props) => {
if (error) {
return (
<div>
<Alert severity="error">
Error encountered while fetching rollbar projects. {error.toString()}
</Alert>
</div>
);
}
return (
<Table
isLoading={loading}
columns={columns}
options={{
search: true,
paging: true,
pageSize: 10,
showEmptyDataSourceMessage: !loading,
}}
title="Projects"
data={entities}
/>
);
};
@@ -17,8 +17,8 @@
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { RollbarTopItemsTable } from './RollbarTopItemsTable';
import { RollbarTopActiveItem } from '../../api/types';
import { RollbarTopItemsTable } from './RollbarTopItemsTable';
const items: RollbarTopActiveItem[] = [
{
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React from 'react';
import { Table, TableColumn } from '@backstage/core';
import { Box, Link, Typography } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import {
RollbarFrameworkId,
RollbarLevel,
+16 -9
View File
@@ -14,29 +14,36 @@
* limitations under the License.
*/
import React from 'react';
import { Routes, Route } from 'react-router';
import { Entity } from '@backstage/catalog-model';
import { MissingAnnotationEmptyState } from '@backstage/core';
import { catalogRouteRef } from '../routes';
import { useEntity } from '@backstage/plugin-catalog-react';
import React from 'react';
import { Route, Routes } from 'react-router';
import { ROLLBAR_ANNOTATION } from '../constants';
import { rootRouteRef } from '../plugin';
import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[ROLLBAR_ANNOTATION]);
type Props = {
entity: Entity;
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
};
export const Router = ({ entity }: Props) =>
!isPluginApplicableToEntity(entity) ? (
<MissingAnnotationEmptyState annotation={ROLLBAR_ANNOTATION} />
) : (
export const Router = (_props: Props) => {
const { entity } = useEntity();
if (!isPluginApplicableToEntity(entity)) {
<MissingAnnotationEmptyState annotation={ROLLBAR_ANNOTATION} />;
}
return (
<Routes>
<Route
path={`/${catalogRouteRef.path}`}
path={`/${rootRouteRef.path}`}
element={<EntityPageRollbar entity={entity} />}
/>
</Routes>
);
};
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { TrendGraph } from './TrendGraph';
describe('TrendGraph component', () => {
+10 -4
View File
@@ -14,10 +14,16 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export * from './api';
export * from './routes';
export { Router } from './components/Router';
export { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage';
export { EntityPageRollbar } from './components/EntityPageRollbar/EntityPageRollbar';
export {
isPluginApplicableToEntity,
isPluginApplicableToEntity as isRollbarAvailable,
Router,
} from './components/Router';
export { ROLLBAR_ANNOTATION } from './constants';
export {
EntityRollbarContent,
rollbarPlugin as plugin,
rollbarPlugin,
} from './plugin';
+2 -2
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { plugin } from './plugin';
import { rollbarPlugin } from './plugin';
describe('rollbar', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
expect(rollbarPlugin).toBeDefined();
});
});
+18 -8
View File
@@ -15,17 +15,21 @@
*/
import {
createPlugin,
createApiFactory,
createPlugin,
createRoutableExtension,
createRouteRef,
discoveryApiRef,
} from '@backstage/core';
import { rootRouteRef, entityRouteRef } from './routes';
import { RollbarHome } from './components/RollbarHome/RollbarHome';
import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage';
import { rollbarApiRef } from './api/RollbarApi';
import { RollbarClient } from './api/RollbarClient';
export const plugin = createPlugin({
export const rootRouteRef = createRouteRef({
path: '',
title: 'Rollbar',
});
export const rollbarPlugin = createPlugin({
id: 'rollbar',
apis: [
createApiFactory({
@@ -34,8 +38,14 @@ export const plugin = createPlugin({
factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }),
}),
],
register({ router }) {
router.addRoute(rootRouteRef, RollbarHome);
router.addRoute(entityRouteRef, RollbarProjectPage);
routes: {
entityContent: rootRouteRef,
},
});
export const EntityRollbarContent = rollbarPlugin.provide(
createRoutableExtension({
component: () => import('./components/Router').then(m => m.Router),
mountPoint: rootRouteRef,
}),
);
-36
View File
@@ -1,36 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core';
const NoIcon = () => null;
export const rootRouteRef = createRouteRef({
icon: NoIcon,
path: '/rollbar',
title: 'Rollbar Home',
});
export const entityRouteRef = createRouteRef({
path: '/rollbar/:optionalNamespaceAndName',
title: 'Rollbar',
});
export const catalogRouteRef = createRouteRef({
icon: NoIcon,
path: '',
title: 'Rollbar',
});
+78 -24
View File
@@ -2655,21 +2655,22 @@
resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-5.0.0.tgz#3ba791f37b90e7f6170d252b63aacfcae943c039"
integrity sha512-WmKrB/575EJCzbeSJR3YQ5sET5FaizeljLRw1382qVUeGqzuWBgIS+AF5a0FO51uQTrDpoRgvuHC2IWVsgwkkA==
"@changesets/apply-release-plan@^4.0.0":
version "4.0.0"
resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.0.0.tgz#e78efb56a4e459a8dab814ba43045f2ace0f27c9"
integrity sha512-MrcUd8wIlQ4S/PznzqJVsmnEpUGfPEkCGF54iqt8G05GEqi/zuxpoTfebcScpj5zeiDyxFIcA9RbeZ3pvJJxoA==
"@changesets/apply-release-plan@^4.2.0":
version "4.2.0"
resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.2.0.tgz#f1005815f27c3238f66507e90c6ae14d8fc62b41"
integrity sha512-/vt6UwgQldhOw93Gb8llI5OuYGlJt2+U45AfcXsoxzl8gZzCmChGm3vUaQJYbmtL8TbL8OOVXHRIKJJidMNPKw==
dependencies:
"@babel/runtime" "^7.4.4"
"@changesets/config" "^1.2.0"
"@babel/runtime" "^7.10.4"
"@changesets/config" "^1.5.0"
"@changesets/get-version-range-type" "^0.3.2"
"@changesets/git" "^1.0.5"
"@changesets/types" "^3.1.0"
"@changesets/types" "^3.3.0"
"@manypkg/get-packages" "^1.0.1"
detect-indent "^6.0.0"
fs-extra "^7.0.1"
lodash.startcase "^4.4.0"
outdent "^0.5.0"
prettier "^1.18.2"
prettier "^1.19.1"
resolve-from "^5.0.0"
semver "^5.4.1"
@@ -2685,23 +2686,35 @@
"@manypkg/get-packages" "^1.0.1"
semver "^5.4.1"
"@changesets/cli@^2.11.0":
version "2.12.0"
resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.12.0.tgz#26124b051e6ce6dcc5aa4595588c8cb2ce3e4363"
integrity sha512-dGdFkg75zsaEObsG8gwMLglS6sJVjXWwgVTAzEIjqIoWVnKwqZqccTb4gn0noq47uCwy7SqxiAJqGibIy9UOKw==
"@changesets/assemble-release-plan@^4.1.0":
version "4.1.0"
resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.1.0.tgz#091e5e768dfe51835937e71d1ebaca1c9d6de55b"
integrity sha512-dMqe2L5Pn4UGTW89kOuuCuZD3pQFZj1Sxv92ZW4S98sXGsxcb2PdW+PeHbQ7tawkCYCOvzhXxAlN4OdF2DlDKQ==
dependencies:
"@babel/runtime" "^7.10.4"
"@changesets/apply-release-plan" "^4.0.0"
"@changesets/assemble-release-plan" "^4.0.0"
"@changesets/config" "^1.4.0"
"@changesets/errors" "^0.1.4"
"@changesets/get-dependents-graph" "^1.1.3"
"@changesets/get-dependents-graph" "^1.2.0"
"@changesets/types" "^3.3.0"
"@manypkg/get-packages" "^1.0.1"
semver "^5.4.1"
"@changesets/cli@^2.14.0":
version "2.14.0"
resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.14.0.tgz#b8d1d33d832c640ce0b95333bbd8d5ac5b9c9824"
integrity sha512-rbQMRDXl1cXOglnjUvYyrFLlYBbS0YZdfxZfW3ZbGLzLoS4n50+B9fLSE9oW20hQuL3zAnyLyacb9cwNhF2lig==
dependencies:
"@babel/runtime" "^7.10.4"
"@changesets/apply-release-plan" "^4.2.0"
"@changesets/assemble-release-plan" "^4.1.0"
"@changesets/config" "^1.5.0"
"@changesets/errors" "^0.1.4"
"@changesets/get-dependents-graph" "^1.2.0"
"@changesets/get-release-plan" "^2.0.1"
"@changesets/git" "^1.0.6"
"@changesets/git" "^1.1.0"
"@changesets/logger" "^0.0.5"
"@changesets/pre" "^1.0.4"
"@changesets/read" "^0.4.6"
"@changesets/types" "^3.2.0"
"@changesets/types" "^3.3.0"
"@changesets/write" "^0.1.3"
"@manypkg/get-packages" "^1.0.1"
"@types/semver" "^6.0.0"
@@ -2721,7 +2734,7 @@
term-size "^2.1.0"
tty-table "^2.8.10"
"@changesets/config@^1.2.0", "@changesets/config@^1.4.0":
"@changesets/config@^1.2.0":
version "1.4.0"
resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6"
integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q==
@@ -2734,6 +2747,19 @@
fs-extra "^7.0.1"
micromatch "^4.0.2"
"@changesets/config@^1.5.0":
version "1.5.0"
resolved "https://registry.npmjs.org/@changesets/config/-/config-1.5.0.tgz#6d58b01e45916318d3f39e9cde86a5d69dc58ed8"
integrity sha512-Bl9nLVYcwFCpd9jpzcOsExZk1NuTYX20D2YWHCdS1xll3W0yOdSUlWLUCCfugN1l3+yTR6iDW6q9o6vpCevWvA==
dependencies:
"@changesets/errors" "^0.1.4"
"@changesets/get-dependents-graph" "^1.2.0"
"@changesets/logger" "^0.0.5"
"@changesets/types" "^3.3.0"
"@manypkg/get-packages" "^1.0.1"
fs-extra "^7.0.1"
micromatch "^4.0.2"
"@changesets/errors@^0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@changesets/errors/-/errors-0.1.4.tgz#f79851746c43679a66b383fdff4c012f480f480d"
@@ -2752,6 +2778,17 @@
fs-extra "^7.0.1"
semver "^5.4.1"
"@changesets/get-dependents-graph@^1.2.0":
version "1.2.0"
resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.2.0.tgz#6986927a5fec60bc6fcc76f966efae2ac30c05ed"
integrity sha512-96NInEKpEZH8KvmXyh42PynXVAdq3kQ9VjAeswHtJ3umUCeTF42b/KVXaov+5P1RNnaUVtRuEwzs4syGuowDTw==
dependencies:
"@changesets/types" "^3.3.0"
"@manypkg/get-packages" "^1.0.1"
chalk "^2.1.0"
fs-extra "^7.0.1"
semver "^5.4.1"
"@changesets/get-release-plan@^2.0.1":
version "2.0.1"
resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-2.0.1.tgz#b95d8f1a3cc719ff4b42b9b9aae72458d8787c13"
@@ -2770,7 +2807,7 @@
resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67"
integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg==
"@changesets/git@^1.0.5", "@changesets/git@^1.0.6":
"@changesets/git@^1.0.5":
version "1.0.6"
resolved "https://registry.npmjs.org/@changesets/git/-/git-1.0.6.tgz#057e627e5d3fcb74bf6c18d7284e130ba5a7632e"
integrity sha512-e0M06XuME3W5lGhz+CO0vLc60u+hLk/pYjOx/6GXEWuQrwtGgeycFIfRgRt8qTs664o1oKtVHBbd7ItpoWgFfA==
@@ -2782,6 +2819,18 @@
is-subdir "^1.1.1"
spawndamnit "^2.0.0"
"@changesets/git@^1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@changesets/git/-/git-1.1.0.tgz#ecaa8058ac87b450c09da0e74b68e6854cd0742c"
integrity sha512-f/2rQynT+JiAL/V0V/GQdXhLkcb86ELg3UwH3fQO4wVdfUbE9NHIHq9ohJdH1Ymh0Lv48F/b38aWZ5v2sKiF3w==
dependencies:
"@babel/runtime" "^7.10.4"
"@changesets/errors" "^0.1.4"
"@changesets/types" "^3.1.1"
"@manypkg/get-packages" "^1.0.1"
is-subdir "^1.1.1"
spawndamnit "^2.0.0"
"@changesets/logger@^0.0.5":
version "0.0.5"
resolved "https://registry.npmjs.org/@changesets/logger/-/logger-0.0.5.tgz#68305dd5a643e336be16a2369cb17cdd8ed37d4c"
@@ -2827,6 +2876,11 @@
resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035"
integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg==
"@changesets/types@^3.3.0":
version "3.3.0"
resolved "https://registry.npmjs.org/@changesets/types/-/types-3.3.0.tgz#04cd8184b2d2da760667bd89bf9b930938dbd96e"
integrity sha512-rJamRo+OD/MQekImfIk07JZwYSB18iU6fYL8xOg0gfAiTh1a1+OlR1fPIxm55I7RsWw812is2YcPPwXdIewrhA==
"@changesets/write@^0.1.3":
version "0.1.3"
resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.3.tgz#00ae575af50274773d7493e77fb96838a08ad8ad"
@@ -7686,9 +7740,9 @@
integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw==
"@types/zen-observable@^0.8.0":
version "0.8.0"
resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d"
integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==
version "0.8.2"
resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"
integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==
"@typescript-eslint/eslint-plugin@^v4.14.0":
version "4.14.0"
@@ -21260,7 +21314,7 @@ prepend-http@^2.0.0:
resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
prettier@^1.18.2:
prettier@^1.18.2, prettier@^1.19.1:
version "1.19.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==