Merge branch 'master' into canon-forms
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
---
|
||||
|
||||
Notifications are now automatically deleted after 1 year by default.
|
||||
|
||||
There is a new scheduled task that runs every 24 hours to delete notifications older than 1 year.
|
||||
This can be configured by setting the `notifications.retention` in the `app-config.yaml` file.
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
retention: 1y
|
||||
```
|
||||
|
||||
If the retention is set to false, notifications will not be automatically deleted.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': minor
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Added information about the `entityRef` and `taskId` to the analytics events whenever is possible.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Added new rate limit middleware to allow rate limiting requests to the backend
|
||||
|
||||
If you are using the `configure` callback of the root HTTP router service and do NOT call `applyDefaults()` inside it, please see [the relevant changes](https://github.com/backstage/backstage/pull/28708/files#diff-86ad1b6a694dd250823aee39d410428dd837c9d9a04ca8c33bd1081fbe3f22af) that were made, to see if you want to apply them as well to your custom configuration.
|
||||
Rate limiting can be turned on by adding the following configuration to `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
rateLimit:
|
||||
window: 6s
|
||||
incomingRequestLimit: 100
|
||||
```
|
||||
|
||||
Plugin specific rate limiting can be configured by adding the following configuration to `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
rateLimit:
|
||||
global: false # This will disable the global rate limiting
|
||||
plugin:
|
||||
catalog:
|
||||
window: 6s
|
||||
incomingRequestLimit: 100
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'yarn-plugin-backstage': patch
|
||||
---
|
||||
|
||||
added functionality so that adding or updating a backstage dependency to a package would maintain the "backstage:^" placeholder for the version.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/canon': patch
|
||||
---
|
||||
|
||||
The filter input in menu comboboxes should now always use the full width of the menu it's in.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-ldap': patch
|
||||
---
|
||||
|
||||
Added the ability to configure disabling one side of the relations tree with LDAP.
|
||||
|
||||
Groups have a `member` attribute and users have a `memberOf` attribute, however these can drift out of sync in some LDAP installations, leaving weird states in the Catalog as we collate these results together and deduplicate them.
|
||||
|
||||
You can chose to optionally disable one side of these relationships, or even both by setting the respective mapping to `null` in your `app-config.yaml` for your groups and/or users:
|
||||
|
||||
```yaml
|
||||
catalog:
|
||||
providers:
|
||||
ldapOrg:
|
||||
default:
|
||||
target: ldaps://ds.example.net
|
||||
bind:
|
||||
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
|
||||
secret: ${LDAP_SECRET}
|
||||
users:
|
||||
- dn: ou=people,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (uid=*)
|
||||
map:
|
||||
# this ensures that outgoing memberships from users is ignored
|
||||
memberOf: null
|
||||
groups:
|
||||
- dn: ou=access,ou=groups,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (&(objectClass=some-group-class)(!(groupType=email)))
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
map:
|
||||
# this ensures that outgoing memberships from groups is ignored
|
||||
members: null
|
||||
```
|
||||
@@ -36,6 +36,12 @@ backend:
|
||||
# keys:
|
||||
# - secret: ${BACKEND_SECRET}
|
||||
|
||||
# Used for testing rate limiting locally
|
||||
# rateLimit:
|
||||
# windowMs: 1m
|
||||
# incomingRequestLimit: 1
|
||||
# ipAllowList: []
|
||||
|
||||
auth:
|
||||
# TODO: once plugins have been migrated we can remove this, but right now it
|
||||
# is require for the backend-next to work in this repo
|
||||
|
||||
@@ -68,6 +68,60 @@ For those routes you will also have to specify `allowLimitedAccess: true` when
|
||||
using the [`auth`](./auth.md) and [`httpAuth`](./http-auth.md) services to
|
||||
access the incoming credentials.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Rate limiting allows you to limit the amount of requests users can send to you backend.
|
||||
This is useful for blocking various network attacks, such as DDOS.
|
||||
|
||||
To enable rate limiting, add the following to your config:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
rateLimit: true
|
||||
```
|
||||
|
||||
You can additionally configure the rate limiting parameters, also by plugin:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
rateLimit:
|
||||
global: true # Enables or disables rate limit for all plugins
|
||||
window: 6s # Time window for rate limiting for single client
|
||||
incomingRequestLimit: 100 # Number of requests to accept from one client during time window
|
||||
ipAllowList: ['127.0.0.1'] # IPs to bypass rate limiting
|
||||
skipSuccesfulRequests: false # Rate limit successful requests
|
||||
skipFailedRequests: false # Rate limit failed requests
|
||||
plugin:
|
||||
# Plugin specific rate limiting
|
||||
catalog:
|
||||
window: 3s
|
||||
incomingRequestLimit: 50
|
||||
```
|
||||
|
||||
By default, the rate limiting is per instance and the request counts are stored into memory.
|
||||
If you want to share this information across all your backstage instances, you have to configure
|
||||
the rate limiting store:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
rateLimit:
|
||||
global: true
|
||||
store:
|
||||
type: redis
|
||||
connection: redis://127.0.0.1:16379
|
||||
```
|
||||
|
||||
If your instance is working behind a proxy, you have to configure the backend to trust the proxy
|
||||
for the rate limiting being able to distinguish clients.
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
trustProxy: true
|
||||
```
|
||||
|
||||
For more information about the trust proxy configuration and available options,
|
||||
please refer to [express documentation](https://expressjs.com/en/guide/behind-proxies.html).
|
||||
|
||||
## Configuring the service
|
||||
|
||||
For more advanced customization, there are several APIs from the `@backstage/backend-defaults/httpRouter` package that allow you to customize the implementation of the config service. The default implementation uses all of the middleware exported from `@backstage/backend-defaults/httpRouter`, including `createLifecycleMiddleware`, `createAuthIntegrationRouter`, `createCredentialsBarrier` and `createCookieAuthRefreshMiddleware`. You can use these to create your own `httpRouter` service implementation, for example - here's how you would add a custom health check route to all plugins:
|
||||
@@ -78,6 +132,7 @@ import {
|
||||
createCookieAuthRefreshMiddleware,
|
||||
createCredentialsBarrier,
|
||||
createAuthIntegrationRouter,
|
||||
createRateLimitMiddleware,
|
||||
} from '@backstage/backend-defaults/httpRouter';
|
||||
import { createServiceFactory } from '@backstage/backend-plugin-api';
|
||||
|
||||
@@ -105,6 +160,11 @@ backend.add(
|
||||
}) {
|
||||
const router = PromiseRouter();
|
||||
|
||||
// Optional rate limiting middleware
|
||||
router.use(
|
||||
createRateLimitMiddleware({ pluginId: plugin.getId(), config }),
|
||||
);
|
||||
|
||||
rootHttpRouter.use(`/api/${plugin.getId()}`, router);
|
||||
|
||||
const credentialsBarrier = createCredentialsBarrier({
|
||||
|
||||
@@ -40,6 +40,10 @@ createBackendPlugin({
|
||||
});
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Please refer to the [HTTP Router documentation](./http-router.md#rate-limiting).
|
||||
|
||||
## Configuring the service
|
||||
|
||||
### Via `app-config.yaml`
|
||||
@@ -121,6 +125,11 @@ backend.add(
|
||||
app.use(middleware.cors());
|
||||
app.use(middleware.compression());
|
||||
|
||||
// Optional rate limiting middleware
|
||||
app.use(middleware.rateLimit());
|
||||
// If you are using rate limiting behind a proxy, you should set the `trust proxy` setting to true
|
||||
app.set('trust proxy', true);
|
||||
|
||||
app.use(healthRouter);
|
||||
|
||||
// you can add you your own middleware in here
|
||||
|
||||
@@ -158,6 +158,21 @@ You can customize the origin names shown in the UI by passing an object where th
|
||||
|
||||
Each notification processor will receive its own row in the settings page, where the user can enable or disable notifications from that processor.
|
||||
|
||||
### Automatic notification cleanup
|
||||
|
||||
Notifications are deleted automatically after a certain period of time to prevent the database from growing indefinitely
|
||||
and to keep the user interface clean. The default retention period is set to 1 year, meaning that notifications older
|
||||
than that will be deleted automatically.
|
||||
|
||||
The retention period can be configured by setting the `notifications.retention` in the `app-config.yaml` file.
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
retention: 1y
|
||||
```
|
||||
|
||||
If the retention is set to false, notifications will not be automatically deleted.
|
||||
|
||||
## Additional info
|
||||
|
||||
An example of a backend plugin sending notifications can be found in the [`@backstage/plugin-scaffolder-backend-module-notifications` package](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend-module-notifications).
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Spacelift.io
|
||||
author: Spacelift.io
|
||||
authorUrl: https://spacelift.io/
|
||||
category: Infrastructure
|
||||
description: The Spacelift plugin allows you to manage your infrastructure directly from Backstage. Visualize your IaC stacks and trigger runs with ease.
|
||||
documentation: https://docs.spacelift.io/integrations/external-integrations/backstage
|
||||
iconUrl: https://avatars.githubusercontent.com/u/53318513?s=200&v=4
|
||||
npmPackageName: '@spacelift-io/backstage-integration-frontend'
|
||||
addedDate: '2025-06-10'
|
||||
+86
@@ -790,6 +790,92 @@ export interface Config {
|
||||
headers?: { [name: string]: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Rate limiting options. Defining this as `true` will enable rate limiting with default values.
|
||||
*/
|
||||
rateLimit?:
|
||||
| true
|
||||
| {
|
||||
store?:
|
||||
| {
|
||||
type: 'redis';
|
||||
connection: string;
|
||||
}
|
||||
| {
|
||||
type: 'memory';
|
||||
};
|
||||
/**
|
||||
* Enable/disable global rate limiting. If this is disabled, plugin specific rate limiting must be
|
||||
* used.
|
||||
*/
|
||||
global?: boolean;
|
||||
/**
|
||||
* Time frame in milliseconds or as human duration for which requests are checked/remembered.
|
||||
* Defaults to one minute.
|
||||
*/
|
||||
window?: string | HumanDuration;
|
||||
/**
|
||||
* The maximum number of connections to allow during the `window` before rate limiting the client.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
incomingRequestLimit?: number;
|
||||
/**
|
||||
* Whether to pass requests in case of store failure.
|
||||
* Defaults to false.
|
||||
*/
|
||||
passOnStoreError?: boolean;
|
||||
/**
|
||||
* List of allowed IP addresses that are not rate limited.
|
||||
* Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1].
|
||||
*/
|
||||
ipAllowList?: string[];
|
||||
/**
|
||||
* Skip rate limiting for requests that have been successful.
|
||||
* Defaults to false.
|
||||
*/
|
||||
skipSuccessfulRequests?: boolean;
|
||||
/**
|
||||
* Skip rate limiting for requests that have failed.
|
||||
* Defaults to false.
|
||||
*/
|
||||
skipFailedRequests?: boolean;
|
||||
/** Plugin specific rate limiting configuration */
|
||||
plugin?: {
|
||||
[pluginId: string]: {
|
||||
/**
|
||||
* Time frame in milliseconds or as human duration for which requests are checked/remembered.
|
||||
* Defaults to one minute.
|
||||
*/
|
||||
window?: string | HumanDuration;
|
||||
/**
|
||||
* The maximum number of connections to allow during the `window` before rate limiting the client.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
incomingRequestLimit?: number;
|
||||
/**
|
||||
* Whether to pass requests in case of store failure.
|
||||
* Defaults to false.
|
||||
*/
|
||||
passOnStoreError?: boolean;
|
||||
/**
|
||||
* List of allowed IP addresses that are not rate limited.
|
||||
* Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1].
|
||||
*/
|
||||
ipAllowList?: string[];
|
||||
/**
|
||||
* Skip rate limiting for requests that have been successful.
|
||||
* Defaults to false.
|
||||
*/
|
||||
skipSuccessfulRequests?: boolean;
|
||||
/**
|
||||
* Skip rate limiting for requests that have failed.
|
||||
* Defaults to false.
|
||||
*/
|
||||
skipFailedRequests?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration related to URL reading, used for example for reading catalog info
|
||||
* files, scaffolder templates, and techdocs content.
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"cron": "^3.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"git-url-parse": "^15.0.0",
|
||||
"helmet": "^6.0.0",
|
||||
@@ -187,6 +188,7 @@
|
||||
"pg": "^8.11.3",
|
||||
"pg-connection-string": "^2.3.0",
|
||||
"pg-format": "^1.0.4",
|
||||
"rate-limit-redis": "^4.2.0",
|
||||
"raw-body": "^2.4.1",
|
||||
"selfsigned": "^2.0.0",
|
||||
"tar": "^6.1.12",
|
||||
|
||||
@@ -93,6 +93,7 @@ export class MiddlewareFactory {
|
||||
helmet(): RequestHandler;
|
||||
logging(): RequestHandler;
|
||||
notFound(): RequestHandler;
|
||||
rateLimit(): RequestHandler;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -70,6 +70,11 @@ describe('actionsServiceFactory', () => {
|
||||
input: {},
|
||||
output: {},
|
||||
},
|
||||
attributes: {
|
||||
destructive: false,
|
||||
idempotent: false,
|
||||
readOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -306,6 +311,11 @@ describe('actionsServiceFactory', () => {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
destructive: true,
|
||||
idempotent: false,
|
||||
readOnly: false,
|
||||
},
|
||||
title: 'Test',
|
||||
},
|
||||
],
|
||||
|
||||
+9
-2
@@ -67,13 +67,20 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
|
||||
actions: Array.from(this.actions.entries()).map(([id, action]) => ({
|
||||
id,
|
||||
...action,
|
||||
attributes: {
|
||||
// Inspired by the @modelcontextprotocol/sdk defaults for the hints.
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/blob/dd69efa1de8646bb6b195ff8d5f52e13739f4550/src/types.ts#L777-L812
|
||||
destructive: action.attributes?.destructive ?? true,
|
||||
idempotent: action.attributes?.idempotent ?? false,
|
||||
readOnly: action.attributes?.readOnly ?? false,
|
||||
},
|
||||
schema: {
|
||||
input: action.schema?.input
|
||||
? zodToJsonSchema(action.schema.input(z))
|
||||
: zodToJsonSchema(z.any()),
|
||||
: zodToJsonSchema(z.object({})),
|
||||
output: action.schema?.output
|
||||
? zodToJsonSchema(action.schema.output(z))
|
||||
: zodToJsonSchema(z.any()),
|
||||
: zodToJsonSchema(z.object({})),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
+103
@@ -181,6 +181,109 @@ describe('actionsRegistryServiceFactory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set default attributes', async () => {
|
||||
const pluginSubject = createBackendPlugin({
|
||||
pluginId: 'my-plugin',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
actionsRegistry: coreServices.actionsRegistry,
|
||||
},
|
||||
async init({ actionsRegistry }) {
|
||||
actionsRegistry.register({
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
schema: {
|
||||
input: z => z.object({}),
|
||||
output: z => z.object({}),
|
||||
},
|
||||
action: async () => ({ output: { ok: true } }),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [pluginSubject, ...defaultServices],
|
||||
});
|
||||
|
||||
const { body, status } = await request(server).get(
|
||||
'/api/my-plugin/.backstage/actions/v1/actions',
|
||||
);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
actions: [
|
||||
{
|
||||
name: 'test',
|
||||
attributes: {
|
||||
destructive: true,
|
||||
idempotent: false,
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow setting attributes', async () => {
|
||||
const pluginSubject = createBackendPlugin({
|
||||
pluginId: 'my-plugin',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
actionsRegistry: coreServices.actionsRegistry,
|
||||
},
|
||||
async init({ actionsRegistry }) {
|
||||
actionsRegistry.register({
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
attributes: {
|
||||
destructive: false,
|
||||
idempotent: true,
|
||||
readOnly: true,
|
||||
},
|
||||
schema: {
|
||||
input: z => z.object({}),
|
||||
output: z => z.object({}),
|
||||
},
|
||||
action: async () => ({ output: { ok: true } }),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [pluginSubject, ...defaultServices],
|
||||
});
|
||||
|
||||
const { body, status } = await request(server).get(
|
||||
'/api/my-plugin/.backstage/actions/v1/actions',
|
||||
);
|
||||
|
||||
expect(status).toBe(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
actions: [
|
||||
{
|
||||
name: 'test',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
attributes: {
|
||||
destructive: false,
|
||||
idempotent: true,
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should forces registration of input and output schema as objects', async () => {
|
||||
const pluginSubject = createBackendPlugin({
|
||||
pluginId: 'my-plugin',
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025 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 { NextFunction, Request, Response } from 'express';
|
||||
import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts';
|
||||
import { Config } from '@backstage/config';
|
||||
import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts';
|
||||
|
||||
export const createRateLimitMiddleware = (options: {
|
||||
pluginId: string;
|
||||
config: Config;
|
||||
}) => {
|
||||
const { pluginId, config } = options;
|
||||
const configKey = `backend.rateLimit.plugin.${pluginId}`;
|
||||
const enabled = config.has(configKey);
|
||||
if (!enabled) {
|
||||
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
const rateLimitOptions = config.getConfig(configKey);
|
||||
|
||||
return rateLimitMiddleware({
|
||||
store: RateLimitStoreFactory.create({ config, prefix: pluginId }),
|
||||
config: rateLimitOptions,
|
||||
});
|
||||
};
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
HttpRouterServiceAuthPolicy,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
createLifecycleMiddleware,
|
||||
createAuthIntegrationRouter,
|
||||
createCookieAuthRefreshMiddleware,
|
||||
createCredentialsBarrier,
|
||||
createAuthIntegrationRouter,
|
||||
createLifecycleMiddleware,
|
||||
} from './http';
|
||||
import { MiddlewareFactory } from '../rootHttpRouter';
|
||||
import { createRateLimitMiddleware } from './http/createRateLimitMiddleware.ts';
|
||||
|
||||
/**
|
||||
* HTTP route registration for plugins.
|
||||
@@ -61,6 +62,8 @@ export const httpRouterServiceFactory = createServiceFactory({
|
||||
}) {
|
||||
const router = PromiseRouter();
|
||||
|
||||
router.use(createRateLimitMiddleware({ pluginId: plugin.getId(), config }));
|
||||
|
||||
rootHttpRouter.use(`/api/${plugin.getId()}`, router);
|
||||
|
||||
const credentialsBarrier = createCredentialsBarrier({
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
RootConfigService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
Request,
|
||||
Response,
|
||||
ErrorRequestHandler,
|
||||
NextFunction,
|
||||
Request,
|
||||
RequestHandler,
|
||||
Response,
|
||||
} from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
@@ -37,12 +37,14 @@ import {
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
NotModifiedError,
|
||||
ServiceUnavailableError,
|
||||
serializeError,
|
||||
ServiceUnavailableError,
|
||||
} from '@backstage/errors';
|
||||
import { NotImplementedError } from '@backstage/errors';
|
||||
import { applyInternalErrorFilter } from './applyInternalErrorFilter';
|
||||
import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts';
|
||||
import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts';
|
||||
|
||||
type LogMeta = {
|
||||
date: string;
|
||||
@@ -227,6 +229,47 @@ export class MiddlewareFactory {
|
||||
return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a middleware that implements rate limiting.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Rate limiting is a common technique to prevent abuse of APIs. This middleware is
|
||||
* configured using the config key `backend.rateLimit`.
|
||||
*
|
||||
* @returns An Express request handler
|
||||
*/
|
||||
rateLimit(): RequestHandler {
|
||||
const enabled = this.#config.has('backend.rateLimit');
|
||||
if (!enabled) {
|
||||
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
const useDefaults = this.#config.getOptional('backend.rateLimit') === true;
|
||||
const rateLimitOptions = useDefaults
|
||||
? undefined
|
||||
: this.#config.getOptionalConfig('backend.rateLimit');
|
||||
|
||||
// Global rate limiting disabled
|
||||
if (
|
||||
rateLimitOptions &&
|
||||
rateLimitOptions.getOptionalBoolean('global') === false
|
||||
) {
|
||||
return (_req: Request, _res: Response, next: NextFunction) => {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return rateLimitMiddleware({
|
||||
store: useDefaults
|
||||
? undefined
|
||||
: RateLimitStoreFactory.create({ config: this.#config }),
|
||||
config: rateLimitOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware to handle errors during request processing.
|
||||
*
|
||||
|
||||
+3
-2
@@ -15,13 +15,13 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
RootConfigService,
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
LifecycleService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import express, { RequestHandler, Express } from 'express';
|
||||
import express, { Express, RequestHandler } from 'express';
|
||||
import type { Server } from 'node:http';
|
||||
import {
|
||||
createHttpServer,
|
||||
@@ -121,6 +121,7 @@ const rootHttpRouterServiceFactoryWithOptions = (
|
||||
app.use(middleware.cors());
|
||||
app.use(middleware.compression());
|
||||
app.use(middleware.logging());
|
||||
app.use(middleware.rateLimit());
|
||||
app.use(healthRouter);
|
||||
app.use(routes);
|
||||
app.use(middleware.notFound());
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2024 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 { mockServices } from '@backstage/backend-test-utils';
|
||||
import { RateLimitStoreFactory } from './RateLimitStoreFactory.ts';
|
||||
import { RedisStore } from 'rate-limit-redis';
|
||||
|
||||
jest.mock('@keyv/redis', () => {
|
||||
const Actual = jest.requireActual('@keyv/redis');
|
||||
return {
|
||||
...Actual,
|
||||
__esModule: true,
|
||||
default: jest.fn(() => {
|
||||
return {
|
||||
getClient: jest.fn(() => ({
|
||||
sendCommand: jest.fn().mockReturnValue('mock'),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('CacheRateLimitStoreFactory', () => {
|
||||
afterEach(jest.clearAllMocks);
|
||||
|
||||
it('should return undefined store with auto configuration if redis is not available', () => {
|
||||
const config = mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
rateLimit: {
|
||||
store: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const store = RateLimitStoreFactory.create({ config });
|
||||
expect(store).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return redis store if configured explicitly', async () => {
|
||||
const config = mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
rateLimit: {
|
||||
store: {
|
||||
type: 'redis',
|
||||
connection: 'redis://localhost:6379',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const store = RateLimitStoreFactory.create({ config });
|
||||
expect(store).toBeInstanceOf(RedisStore);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2024 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 { Config } from '@backstage/config';
|
||||
import type { Store } from 'express-rate-limit';
|
||||
import { RedisStore } from 'rate-limit-redis';
|
||||
|
||||
/**
|
||||
* Creates a store for `express-rate-limit` based on the configuration.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class RateLimitStoreFactory {
|
||||
static create(options: {
|
||||
config: Config;
|
||||
prefix?: string;
|
||||
}): Store | undefined {
|
||||
const { config, prefix } = options;
|
||||
const store = config.getOptionalConfig('backend.rateLimit.store');
|
||||
if (!store) {
|
||||
return undefined;
|
||||
}
|
||||
const type = store.getString('type');
|
||||
switch (type) {
|
||||
case 'redis':
|
||||
return this.redis({ store, prefix });
|
||||
case 'memory':
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static redis(options: { store: Config; prefix?: string }): Store {
|
||||
const { store, prefix } = options;
|
||||
const connectionString = store.getString('connection');
|
||||
const KeyvRedis = require('@keyv/redis').default;
|
||||
const keyv = new KeyvRedis(connectionString);
|
||||
return new RedisStore({
|
||||
prefix,
|
||||
sendCommand: async (...args: string[]) => {
|
||||
const client = await keyv.getClient();
|
||||
return client.sendCommand(args);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2025 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 { RequestHandler } from 'express';
|
||||
import { rateLimit, Store } from 'express-rate-limit';
|
||||
import { Config, readDurationFromConfig } from '@backstage/config';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
|
||||
export const rateLimitMiddleware = (options: {
|
||||
store?: Store;
|
||||
config?: Config;
|
||||
}): RequestHandler => {
|
||||
const { store, config } = options;
|
||||
let windowMs: number = 60000;
|
||||
if (config && config.has('window')) {
|
||||
const windowDuration = readDurationFromConfig(config, {
|
||||
key: 'window',
|
||||
});
|
||||
windowMs = durationToMilliseconds(windowDuration);
|
||||
}
|
||||
const limit = config?.getOptionalNumber('incomingRequestLimit');
|
||||
const ipAllowList = config?.getOptionalStringArray('ipAllowList') ?? [
|
||||
'127.0.0.1',
|
||||
'0:0:0:0:0:0:0:1',
|
||||
'::1',
|
||||
];
|
||||
const skipSuccessfulRequests = config?.getOptionalBoolean(
|
||||
'skipSuccessfulRequests',
|
||||
);
|
||||
const skipFailedRequests = config?.getOptionalBoolean('skipFailedRequests');
|
||||
const passOnStoreError = config?.getOptionalBoolean('passOnStoreError');
|
||||
|
||||
return rateLimit({
|
||||
windowMs,
|
||||
limit,
|
||||
skipSuccessfulRequests,
|
||||
message: {
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: `Too many requests, please try again later`,
|
||||
},
|
||||
response: {
|
||||
statusCode: 429,
|
||||
},
|
||||
},
|
||||
statusCode: 429,
|
||||
skipFailedRequests,
|
||||
passOnStoreError: passOnStoreError,
|
||||
keyGenerator(req, _res): string {
|
||||
if (!req.ip) {
|
||||
return req.socket.remoteAddress!;
|
||||
}
|
||||
return req.ip;
|
||||
},
|
||||
skip: (req, _res) => {
|
||||
return (
|
||||
Boolean(req.ip && ipAllowList.includes(req.ip)) ||
|
||||
Boolean(
|
||||
req.socket.remoteAddress &&
|
||||
ipAllowList.includes(req.socket.remoteAddress),
|
||||
)
|
||||
);
|
||||
},
|
||||
validate: {
|
||||
trustProxy: false,
|
||||
},
|
||||
store,
|
||||
});
|
||||
};
|
||||
@@ -48,6 +48,11 @@ export type ActionsRegistryActionOptions<
|
||||
input: (zod: typeof z) => TInputSchema;
|
||||
output: (zod: typeof z) => TOutputSchema;
|
||||
};
|
||||
attributes?: {
|
||||
destructive?: boolean;
|
||||
idempotent?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
action: (context: ActionsRegistryActionContext<TInputSchema>) => Promise<
|
||||
z.infer<TOutputSchema> extends void
|
||||
? void
|
||||
@@ -94,6 +99,11 @@ export type ActionsServiceAction = {
|
||||
input: JSONSchema7;
|
||||
output: JSONSchema7;
|
||||
};
|
||||
attributes: {
|
||||
readOnly: boolean;
|
||||
destructive: boolean;
|
||||
idempotent: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ActionsRegistryActionOptions<
|
||||
input: (zod: typeof z) => TInputSchema;
|
||||
output: (zod: typeof z) => TOutputSchema;
|
||||
};
|
||||
attributes?: {
|
||||
destructive?: boolean;
|
||||
idempotent?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
action: (
|
||||
context: ActionsRegistryActionContext<TInputSchema>,
|
||||
) => Promise<
|
||||
|
||||
@@ -29,6 +29,11 @@ export type ActionsServiceAction = {
|
||||
input: JSONSchema7;
|
||||
output: JSONSchema7;
|
||||
};
|
||||
attributes: {
|
||||
readOnly: boolean;
|
||||
destructive: boolean;
|
||||
idempotent: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--canon-border);
|
||||
background-color: var(--canon-bg-surface-1);
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--canon-fg-primary);
|
||||
line-height: 140%;
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
.canon-SubmenuComboboxSearch {
|
||||
padding-inline: var(--canon-space-3);
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--canon-border);
|
||||
|
||||
@@ -82,3 +82,12 @@ specific Backstage repository. As such, when publishing packages, all
|
||||
`backstage:^` versions should be removed from the package.json and replaced with
|
||||
the appropriate npm version ranges. This is handled by the
|
||||
`beforeWorkspacePacking` hook.
|
||||
|
||||
### `afterWorkspaceDependencyAddition` hook
|
||||
|
||||
_Replaces npm version ranges with `backstage:^` ranges for `@backstage/*` dependencies added after
|
||||
the plugin has converted existing dependencies to `backstage:^` range_
|
||||
|
||||
### `afterWorkspaceDependencyReplacement` hook
|
||||
|
||||
_warns user with console message when running `yarn add` for a `@backstage/*` scoped dependency that is already a dependency in the target package. Doing so will remove the `backstage:^` scope and replace it with the actual npm version range, which may not be desired._
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
"dependencies": {
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/release-manifests": "workspace:^",
|
||||
"@yarnpkg/core": "^4.4.0",
|
||||
"@yarnpkg/core": "^4.4.1",
|
||||
"@yarnpkg/fslib": "^3.1.2",
|
||||
"@yarnpkg/plugin-essentials": "^4.4.0",
|
||||
"@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch",
|
||||
"@yarnpkg/plugin-pack": "^4.0.1",
|
||||
"semver": "^7.6.0"
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
```ts
|
||||
import { Hooks } from '@yarnpkg/core';
|
||||
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-pack';
|
||||
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-essentials';
|
||||
import { Hooks as Hooks_3 } from '@yarnpkg/plugin-pack';
|
||||
import { Plugin as Plugin_2 } from '@yarnpkg/core';
|
||||
|
||||
// @public (undocumented)
|
||||
const plugin: Plugin_2<Hooks & Hooks_2>;
|
||||
const plugin: Plugin_2<Hooks & Hooks_2 & Hooks_3>;
|
||||
export default plugin;
|
||||
```
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
Descriptor,
|
||||
DescriptorHash,
|
||||
IdentHash,
|
||||
Workspace,
|
||||
} from '@yarnpkg/core';
|
||||
import { suggestUtils } from '@yarnpkg/plugin-essentials';
|
||||
import { getPackageVersion } from '../util';
|
||||
import { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition';
|
||||
|
||||
jest.mock('../util', () => ({
|
||||
getPackageVersion: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('afterWorkspaceDependencyAddition', () => {
|
||||
const workspace = {
|
||||
project: {
|
||||
configuration: {},
|
||||
},
|
||||
} as Workspace;
|
||||
const target = {} as suggestUtils.Target;
|
||||
const strategies: Array<suggestUtils.Strategy> = [];
|
||||
const mockGetPackageVersion = getPackageVersion as jest.MockedFunction<
|
||||
typeof getPackageVersion
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetPackageVersion.mockReset();
|
||||
});
|
||||
|
||||
it('should replace the range for a backstage scoped dependency', async () => {
|
||||
const input: Descriptor = {
|
||||
scope: 'backstage',
|
||||
name: 'test-package',
|
||||
range: '^1.0.0',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
|
||||
mockGetPackageVersion.mockImplementation(() => Promise.resolve('success'));
|
||||
|
||||
await afterWorkspaceDependencyAddition(
|
||||
workspace,
|
||||
target,
|
||||
input,
|
||||
strategies,
|
||||
);
|
||||
|
||||
expect(input.range).toBe('backstage:^');
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledWith(
|
||||
input,
|
||||
workspace.project.configuration,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not replace the range for a backstage scoped dependency where it cant find a version from remote', async () => {
|
||||
const input: Descriptor = {
|
||||
scope: 'backstage',
|
||||
name: 'test-package',
|
||||
range: '^1.0.0',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
|
||||
mockGetPackageVersion.mockImplementation(() =>
|
||||
Promise.reject(new Error('test error')),
|
||||
);
|
||||
|
||||
await afterWorkspaceDependencyAddition(
|
||||
workspace,
|
||||
target,
|
||||
input,
|
||||
strategies,
|
||||
);
|
||||
|
||||
expect(input.range).toBe('^1.0.0');
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledWith(
|
||||
input,
|
||||
workspace.project.configuration,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not replace the range for a non-backstage scoped dependency', async () => {
|
||||
const input: Descriptor = {
|
||||
scope: 'backstage-community',
|
||||
name: 'test-package',
|
||||
range: '^1.0.0',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
|
||||
await afterWorkspaceDependencyAddition(
|
||||
workspace,
|
||||
target,
|
||||
input,
|
||||
strategies,
|
||||
);
|
||||
|
||||
expect(input.range).toBe('^1.0.0');
|
||||
expect(mockGetPackageVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2025 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 { Descriptor, structUtils, Workspace } from '@yarnpkg/core';
|
||||
import { suggestUtils } from '@yarnpkg/plugin-essentials';
|
||||
import { getPackageVersion } from '../util';
|
||||
import { PROTOCOL } from '../constants';
|
||||
|
||||
export const afterWorkspaceDependencyAddition = async (
|
||||
workspace: Workspace,
|
||||
_target: suggestUtils.Target,
|
||||
descriptor: Descriptor,
|
||||
_strategies: Array<suggestUtils.Strategy>,
|
||||
) => {
|
||||
const descriptorRange = structUtils.parseRange(descriptor.range);
|
||||
|
||||
if (
|
||||
descriptor.scope === 'backstage' &&
|
||||
descriptorRange.protocol !== PROTOCOL
|
||||
) {
|
||||
try {
|
||||
await getPackageVersion(descriptor, workspace.project.configuration);
|
||||
console.info(
|
||||
`Setting ${descriptor.scope}/${descriptor.name} to ${PROTOCOL}^`,
|
||||
);
|
||||
descriptor.range = `${PROTOCOL}^`;
|
||||
} catch (_error: any) {
|
||||
// if there's no found version then this is likely a deprecated package
|
||||
// or otherwise the plugin won't be able to resolve the real version
|
||||
// and we should leave the desired range as is
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
Descriptor,
|
||||
DescriptorHash,
|
||||
IdentHash,
|
||||
Workspace,
|
||||
} from '@yarnpkg/core';
|
||||
import { suggestUtils } from '@yarnpkg/plugin-essentials';
|
||||
import { getPackageVersion } from '../util';
|
||||
import { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement';
|
||||
|
||||
jest.mock('../util', () => ({
|
||||
getPackageVersion: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('afterWorkspaceDependencyReplacement.test', () => {
|
||||
const workspace = {
|
||||
project: {
|
||||
configuration: {},
|
||||
},
|
||||
} as Workspace;
|
||||
const target = {} as suggestUtils.Target;
|
||||
const mockGetPackageVersion = getPackageVersion as jest.MockedFunction<
|
||||
typeof getPackageVersion
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetPackageVersion.mockReset();
|
||||
});
|
||||
|
||||
it('should warn that the range is being changed for a backstage scoped dependency', async () => {
|
||||
const fromDescriptor: Descriptor = {
|
||||
scope: 'backstage',
|
||||
name: 'test-package',
|
||||
range: 'backstage:^',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
const toDescriptor: Descriptor = {
|
||||
scope: 'backstage',
|
||||
name: 'test-package',
|
||||
range: '^1.0.0',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
|
||||
mockGetPackageVersion.mockImplementation(() => Promise.resolve('success'));
|
||||
|
||||
await afterWorkspaceDependencyReplacement(
|
||||
workspace,
|
||||
target,
|
||||
fromDescriptor,
|
||||
toDescriptor,
|
||||
);
|
||||
|
||||
expect(toDescriptor.range).toBe('^1.0.0');
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledWith(
|
||||
toDescriptor,
|
||||
workspace.project.configuration,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn that the range is being changed for a backstage scoped dependency where it cant find a version from remote', async () => {
|
||||
const fromDescriptor: Descriptor = {
|
||||
scope: 'backstage',
|
||||
name: 'test-package',
|
||||
range: 'backstage:^',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
const toDescriptor: Descriptor = {
|
||||
scope: 'backstage',
|
||||
name: 'test-package',
|
||||
range: '^1.0.0',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
|
||||
mockGetPackageVersion.mockImplementation(() =>
|
||||
Promise.reject(new Error('test error')),
|
||||
);
|
||||
|
||||
await afterWorkspaceDependencyReplacement(
|
||||
workspace,
|
||||
target,
|
||||
fromDescriptor,
|
||||
toDescriptor,
|
||||
);
|
||||
|
||||
expect(toDescriptor.range).toBe('^1.0.0');
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetPackageVersion).toHaveBeenCalledWith(
|
||||
toDescriptor,
|
||||
workspace.project.configuration,
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore that the range is being changed for a non-backstage scoped dependency', async () => {
|
||||
const fromDescriptor: Descriptor = {
|
||||
scope: 'backstage-community',
|
||||
name: 'test-package',
|
||||
range: 'backstage:^',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
const toDescriptor: Descriptor = {
|
||||
scope: 'backstage-community',
|
||||
name: 'test-package',
|
||||
range: '^1.0.0',
|
||||
descriptorHash: {} as DescriptorHash,
|
||||
identHash: {} as IdentHash,
|
||||
};
|
||||
await afterWorkspaceDependencyReplacement(
|
||||
workspace,
|
||||
target,
|
||||
fromDescriptor,
|
||||
toDescriptor,
|
||||
);
|
||||
|
||||
expect(toDescriptor.range).toBe('^1.0.0');
|
||||
expect(mockGetPackageVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2025 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 { Descriptor, structUtils, Workspace } from '@yarnpkg/core';
|
||||
import { suggestUtils } from '@yarnpkg/plugin-essentials';
|
||||
import { getPackageVersion } from '../util';
|
||||
import { PROTOCOL } from '../constants';
|
||||
|
||||
export const afterWorkspaceDependencyReplacement = async (
|
||||
workspace: Workspace,
|
||||
_target: suggestUtils.Target,
|
||||
_fromDescriptor: Descriptor,
|
||||
toDescriptor: Descriptor,
|
||||
) => {
|
||||
const toDescriptorRange = structUtils.parseRange(toDescriptor.range);
|
||||
|
||||
if (
|
||||
toDescriptor.scope === 'backstage' &&
|
||||
toDescriptorRange.protocol !== PROTOCOL
|
||||
) {
|
||||
try {
|
||||
await getPackageVersion(toDescriptor, workspace.project.configuration);
|
||||
console.warn(
|
||||
`${toDescriptor.name} should be set to "${PROTOCOL}^" instead of "${toDescriptor.range}". Make sure this change is intentional and not a mistake.`,
|
||||
);
|
||||
} catch (_error: any) {
|
||||
// if there's no found version then this is likely a deprecated package
|
||||
// or otherwise the plugin won't be able to resolve the real version
|
||||
// and we should not warn them.
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -16,3 +16,5 @@
|
||||
|
||||
export { beforeWorkspacePacking } from './beforeWorkspacePacking';
|
||||
export { reduceDependency } from './reduceDependency';
|
||||
export { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition';
|
||||
export { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement';
|
||||
|
||||
@@ -23,7 +23,13 @@
|
||||
|
||||
import { Plugin, Hooks, semverUtils, YarnVersion } from '@yarnpkg/core';
|
||||
import { Hooks as PackHooks } from '@yarnpkg/plugin-pack';
|
||||
import { beforeWorkspacePacking, reduceDependency } from './handlers';
|
||||
import { Hooks as EssentialHooks } from '@yarnpkg/plugin-essentials';
|
||||
import {
|
||||
afterWorkspaceDependencyAddition,
|
||||
afterWorkspaceDependencyReplacement,
|
||||
beforeWorkspacePacking,
|
||||
reduceDependency,
|
||||
} from './handlers';
|
||||
import { BackstageNpmResolver } from './resolvers';
|
||||
|
||||
// All dependencies of the yarn plugin are bundled during the build. Chalk
|
||||
@@ -44,8 +50,10 @@ if (!semverUtils.satisfiesWithPrereleases(YarnVersion, '^4.1.1')) {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
const plugin: Plugin<Hooks & PackHooks> = {
|
||||
const plugin: Plugin<Hooks & EssentialHooks & PackHooks> = {
|
||||
hooks: {
|
||||
afterWorkspaceDependencyAddition,
|
||||
afterWorkspaceDependencyReplacement,
|
||||
reduceDependency,
|
||||
beforeWorkspacePacking,
|
||||
},
|
||||
|
||||
+17
-16
@@ -141,7 +141,7 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.memberOf field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
};
|
||||
}
|
||||
| Array<{
|
||||
@@ -221,7 +221,7 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.memberOf field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -311,12 +311,12 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.parent field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
/**
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.children field of the entity. Defaults to "member".
|
||||
*/
|
||||
members?: string;
|
||||
members?: string | null;
|
||||
};
|
||||
}
|
||||
| Array<{
|
||||
@@ -401,12 +401,12 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.parent field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
/**
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.children field of the entity. Defaults to "member".
|
||||
*/
|
||||
members?: string;
|
||||
members?: string | null;
|
||||
};
|
||||
}>;
|
||||
/**
|
||||
@@ -556,7 +556,7 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.memberOf field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
};
|
||||
}
|
||||
| Array<{
|
||||
@@ -588,6 +588,7 @@ export interface Config {
|
||||
pagePause?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON paths (on a.b.c form) and hard coded values to set on those
|
||||
* paths.
|
||||
@@ -636,7 +637,7 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.memberOf field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -726,12 +727,12 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.parent field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
/**
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.children field of the entity. Defaults to "member".
|
||||
*/
|
||||
members?: string;
|
||||
members?: string | null;
|
||||
};
|
||||
}
|
||||
| Array<{
|
||||
@@ -816,12 +817,12 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.parent field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
/**
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.children field of the entity. Defaults to "member".
|
||||
*/
|
||||
members?: string;
|
||||
members?: string | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -926,7 +927,6 @@ export interface Config {
|
||||
* paths.
|
||||
*
|
||||
* This can be useful for example if you want to hard code a
|
||||
* namespace or similar on the generated entities.
|
||||
*/
|
||||
set?: { [key: string]: JsonValue };
|
||||
/**
|
||||
@@ -969,7 +969,7 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.memberOf field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1006,6 +1006,7 @@ export interface Config {
|
||||
};
|
||||
};
|
||||
/**
|
||||
* @default false
|
||||
* JSON paths (on a.b.c form) and hard coded values to set on those
|
||||
* paths.
|
||||
*
|
||||
@@ -1058,12 +1059,12 @@ export interface Config {
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.parent field of the entity. Defaults to "memberOf".
|
||||
*/
|
||||
memberOf?: string;
|
||||
memberOf?: string | null;
|
||||
/**
|
||||
* The name of the attribute that shall be used for the values of
|
||||
* the spec.children field of the entity. Defaults to "member".
|
||||
*/
|
||||
members?: string;
|
||||
members?: string | null;
|
||||
};
|
||||
};
|
||||
/**
|
||||
|
||||
@@ -63,8 +63,8 @@ export type GroupConfig = {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
memberOf: string;
|
||||
members: string;
|
||||
memberOf: string | null;
|
||||
members: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -259,7 +259,7 @@ export type UserConfig = {
|
||||
displayName: string;
|
||||
email: string;
|
||||
picture?: string;
|
||||
memberOf: string;
|
||||
memberOf: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export type UserConfig = {
|
||||
// Only the scope, filter, attributes, and paged fields are supported. The
|
||||
// default is scope "one" and attributes "*" and "+".
|
||||
options: SearchOptions;
|
||||
|
||||
// JSON paths (on a.b.c form) and hard coded values to set on those paths
|
||||
set?: { [path: string]: JsonValue };
|
||||
// Mappings from well known entity fields, to LDAP attribute names
|
||||
@@ -114,7 +115,7 @@ export type UserConfig = {
|
||||
picture?: string;
|
||||
// The name of the attribute that shall be used for the values of the
|
||||
// spec.memberOf field of the entity. Defaults to "memberOf".
|
||||
memberOf: string;
|
||||
memberOf: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -129,6 +130,7 @@ export type GroupConfig = {
|
||||
// The search options to use.
|
||||
// Only the scope, filter, attributes, and paged fields are supported.
|
||||
options: SearchOptions;
|
||||
|
||||
// JSON paths (on a.b.c form) and hard coded values to set on those paths
|
||||
set?: { [path: string]: JsonValue };
|
||||
// Mappings from well known entity fields, to LDAP attribute names
|
||||
@@ -156,10 +158,10 @@ export type GroupConfig = {
|
||||
picture?: string;
|
||||
// The name of the attribute that shall be used for the values of the
|
||||
// spec.parent field of the entity. Defaults to "memberOf".
|
||||
memberOf: string;
|
||||
memberOf: string | null;
|
||||
// The name of the attribute that shall be used for the values of the
|
||||
// spec.children field of the entity. Defaults to "member".
|
||||
members: string;
|
||||
members: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -210,6 +210,53 @@ describe('readLdapUsers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow skipping memberOf', async () => {
|
||||
client.getVendor.mockResolvedValue(DefaultLdapVendor);
|
||||
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
|
||||
await fn(searchEntry({ memberOf: ['x', 'y', 'z'] }));
|
||||
});
|
||||
|
||||
client.getVendor.mockResolvedValue(DefaultLdapVendor);
|
||||
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
|
||||
await fn(
|
||||
searchEntry({
|
||||
uid: ['uid-value'],
|
||||
description: ['description-value'],
|
||||
cn: ['cn-value'],
|
||||
mail: ['mail-value'],
|
||||
avatarUrl: ['avatarUrl-value'],
|
||||
memberOf: ['x', 'y', 'z'],
|
||||
customDN: ['dn-value'],
|
||||
customUUID: ['uuid-value'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const config: UserConfig[] = [
|
||||
{
|
||||
dn: 'ddd',
|
||||
options: {},
|
||||
|
||||
map: {
|
||||
rdn: 'uid',
|
||||
name: 'uid',
|
||||
description: 'description',
|
||||
displayName: 'cn',
|
||||
email: 'mail',
|
||||
picture: 'avatarUrl',
|
||||
memberOf: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const vendorConfig: VendorConfig = {
|
||||
dnAttributeName: 'customDN',
|
||||
uuidAttributeName: 'customUUID',
|
||||
};
|
||||
|
||||
const { userMemberOf } = await readLdapUsers(client, config, vendorConfig);
|
||||
expect(userMemberOf.size).toBe(0);
|
||||
});
|
||||
|
||||
it('transfers all attributes from Microsoft Active Directory', async () => {
|
||||
client.getVendor.mockResolvedValue(ActiveDirectoryVendor);
|
||||
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
|
||||
@@ -729,6 +776,51 @@ describe('readLdapGroups', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow skipping members', async () => {
|
||||
client.getVendor.mockResolvedValue(DefaultLdapVendor);
|
||||
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
|
||||
await fn(
|
||||
searchEntry({
|
||||
cn: ['cn-value'],
|
||||
description: ['description-value'],
|
||||
tt: ['type-value'],
|
||||
mail: ['mail-value'],
|
||||
avatarUrl: ['avatarUrl-value'],
|
||||
memberOf: ['x', 'y', 'z'],
|
||||
member: ['e', 'f', 'g'],
|
||||
customDN: ['dn-value'],
|
||||
customUUID: ['uuid-value'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const config: GroupConfig[] = [
|
||||
{
|
||||
dn: 'ddd',
|
||||
options: {},
|
||||
map: {
|
||||
rdn: 'cn',
|
||||
name: 'cn',
|
||||
description: 'description',
|
||||
displayName: 'cn',
|
||||
email: 'mail',
|
||||
picture: 'avatarUrl',
|
||||
type: 'tt',
|
||||
memberOf: 'memberOf',
|
||||
members: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const vendorConfig: VendorConfig = {
|
||||
dnAttributeName: 'customDN',
|
||||
uuidAttributeName: 'customUUID',
|
||||
};
|
||||
|
||||
const { groupMember } = await readLdapGroups(client, config, vendorConfig);
|
||||
|
||||
expect(groupMember.size).toBe(0);
|
||||
});
|
||||
|
||||
it('can process a list of GroupConfigs', async () => {
|
||||
client.getVendor.mockResolvedValue(DefaultLdapVendor);
|
||||
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
|
||||
|
||||
@@ -146,6 +146,7 @@ export async function readLdapUsers(
|
||||
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(userMemberOf, myDn, vs);
|
||||
});
|
||||
|
||||
entities.push(entity);
|
||||
});
|
||||
}
|
||||
@@ -277,6 +278,7 @@ export async function readLdapGroups(
|
||||
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
|
||||
ensureItems(groupMemberOf, myDn, vs);
|
||||
});
|
||||
|
||||
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
|
||||
ensureItems(groupMember, myDn, vs);
|
||||
});
|
||||
@@ -349,7 +351,7 @@ export async function readLdapOrg(
|
||||
function mapReferencesAttr(
|
||||
entry: SearchEntry,
|
||||
vendor: LdapVendor,
|
||||
attributeName: string | undefined,
|
||||
attributeName: string | undefined | null,
|
||||
setter: (sourceDn: string, targets: string[]) => void,
|
||||
) {
|
||||
if (attributeName) {
|
||||
|
||||
+5
@@ -28,5 +28,10 @@ export interface Config {
|
||||
* Throttle duration between notification sending, defaults to 50ms
|
||||
*/
|
||||
throttleInterval?: HumanDuration | string;
|
||||
/**
|
||||
* Time to keep the notifications in the database, defaults to 365 days.
|
||||
* Can be disabled by setting to false.
|
||||
*/
|
||||
retention?: HumanDuration | string | false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -805,5 +805,24 @@ describe.each(databases.eachSupportedId())(
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearNotifications', () => {
|
||||
it('should clear notifications older than specified days', async () => {
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 10); // 10 days ago
|
||||
await storage.saveNotification({
|
||||
...testNotification1,
|
||||
created: oldDate,
|
||||
});
|
||||
await storage.saveNotification(testNotification2);
|
||||
|
||||
const result = await storage.clearNotifications({
|
||||
maxAge: { days: 5 },
|
||||
}); // Clear notifications older than 5 days
|
||||
expect(result.deletedCount).toBe(1); // Only the first notification should be cleared
|
||||
const remainingNotifications = await storage.getNotifications({ user });
|
||||
expect(remainingNotifications.map(idOnly)).toEqual([id2]);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { Knex } from 'knex';
|
||||
import crypto from 'crypto';
|
||||
import { durationToMilliseconds, HumanDuration } from '@backstage/types';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-notifications-backend',
|
||||
@@ -656,4 +657,24 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
.distinct(['topic']);
|
||||
return { topics: topics.map(row => row.topic) };
|
||||
}
|
||||
|
||||
async clearNotifications(options: {
|
||||
maxAge: HumanDuration;
|
||||
}): Promise<{ deletedCount: number }> {
|
||||
const ms = durationToMilliseconds(options.maxAge);
|
||||
const now = new Date(new Date().getTime() - ms);
|
||||
const notificationsCount = await this.db('notification')
|
||||
.where(builder => {
|
||||
builder.where('created', '<=', now).whereNull('updated');
|
||||
})
|
||||
.orWhere('updated', '<=', now)
|
||||
.delete();
|
||||
const broadcastsCount = await this.db('broadcast')
|
||||
.where(builder => {
|
||||
builder.where('created', '<=', now).whereNull('updated');
|
||||
})
|
||||
.orWhere('updated', '<=', now)
|
||||
.delete();
|
||||
return { deletedCount: notificationsCount + broadcastsCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NotificationSeverity,
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
/** @internal */
|
||||
export type EntityOrder = {
|
||||
@@ -99,6 +100,10 @@ export interface NotificationsStore {
|
||||
user: string;
|
||||
}): Promise<{ origins: string[] }>;
|
||||
|
||||
getUserNotificationTopics(options: {
|
||||
user: string;
|
||||
}): Promise<{ topics: { origin: string; topic: string }[] }>;
|
||||
|
||||
getNotificationSettings(options: {
|
||||
user: string;
|
||||
}): Promise<NotificationSettings>;
|
||||
@@ -109,4 +114,8 @@ export interface NotificationsStore {
|
||||
}): Promise<void>;
|
||||
|
||||
getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>;
|
||||
|
||||
clearNotifications(options: {
|
||||
maxAge: HumanDuration;
|
||||
}): Promise<{ deletedCount: number }>;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
NotificationsProcessingExtensionPoint,
|
||||
} from '@backstage/plugin-notifications-node';
|
||||
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
|
||||
import { DatabaseNotificationsStore } from './database';
|
||||
import { NotificationCleaner } from './service/NotificationCleaner.ts';
|
||||
|
||||
class NotificationsProcessingExtensionPointImpl
|
||||
implements NotificationsProcessingExtensionPoint
|
||||
@@ -69,6 +71,7 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
signals: signalsServiceRef,
|
||||
config: coreServices.rootConfig,
|
||||
catalog: catalogServiceRef,
|
||||
scheduler: coreServices.scheduler,
|
||||
},
|
||||
async init({
|
||||
auth,
|
||||
@@ -80,7 +83,10 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
signals,
|
||||
config,
|
||||
catalog,
|
||||
scheduler,
|
||||
}) {
|
||||
const store = await DatabaseNotificationsStore.create({ database });
|
||||
|
||||
httpRouter.use(
|
||||
await createRouter({
|
||||
auth,
|
||||
@@ -88,7 +94,7 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
userInfo,
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
store,
|
||||
catalog,
|
||||
signals,
|
||||
processors: processingExtensions.processors,
|
||||
@@ -98,6 +104,14 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
path: '/health',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
|
||||
const cleaner = new NotificationCleaner(
|
||||
config,
|
||||
scheduler,
|
||||
logger,
|
||||
store,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2025 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 { LoggerService, SchedulerService } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { NotificationsStore } from '../database';
|
||||
import { NotificationCleaner } from './NotificationCleaner.ts';
|
||||
|
||||
describe('NotificationCleaner', () => {
|
||||
let mockConfig: Config;
|
||||
let mockScheduler: SchedulerService;
|
||||
let mockLogger: LoggerService;
|
||||
let mockDatabase: NotificationsStore;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = mockServices.rootConfig();
|
||||
mockScheduler = mockServices.scheduler.mock();
|
||||
mockLogger = mockServices.logger.mock();
|
||||
mockDatabase = {
|
||||
clearNotifications: jest.fn(),
|
||||
} as unknown as NotificationsStore;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initNotificationCleaner', () => {
|
||||
it('should initialize the notification cleaner with the correct schedule', async () => {
|
||||
const mockTaskRunner = {
|
||||
run: jest.fn(),
|
||||
};
|
||||
mockScheduler.createScheduledTaskRunner = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockTaskRunner);
|
||||
|
||||
const cleaner = new NotificationCleaner(
|
||||
mockConfig,
|
||||
mockScheduler,
|
||||
mockLogger,
|
||||
mockDatabase,
|
||||
);
|
||||
expect(cleaner).toBeInstanceOf(NotificationCleaner);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
expect(mockScheduler.createScheduledTaskRunner).toHaveBeenCalled();
|
||||
expect(mockTaskRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'notification-cleaner',
|
||||
fn: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create a task runner if retention is disabled', async () => {
|
||||
mockConfig = mockServices.rootConfig({
|
||||
data: { notifications: { retention: false } },
|
||||
});
|
||||
const cleaner = new NotificationCleaner(
|
||||
mockConfig,
|
||||
mockScheduler,
|
||||
mockLogger,
|
||||
mockDatabase,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
expect(mockScheduler.createScheduledTaskRunner).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Notification retention is disabled, skipping notification cleaner task',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearNotifications', () => {
|
||||
it('should clear notifications', async () => {
|
||||
mockDatabase.clearNotifications = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deletedCount: 1 });
|
||||
const mockTaskRunner = {
|
||||
run: jest.fn().mockImplementation(({ fn }) => fn()),
|
||||
};
|
||||
mockScheduler.createScheduledTaskRunner = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockTaskRunner);
|
||||
|
||||
const cleaner = new NotificationCleaner(
|
||||
mockConfig,
|
||||
mockScheduler,
|
||||
mockLogger,
|
||||
mockDatabase,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Starting notification cleaner task',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Notification cleaner task completed successfully, deleted 1 notifications',
|
||||
);
|
||||
expect(mockDatabase.clearNotifications).toHaveBeenCalledWith({
|
||||
maxAge: { years: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
LoggerService,
|
||||
SchedulerService,
|
||||
SchedulerServiceTaskScheduleDefinition,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config, readDurationFromConfig } from '@backstage/config';
|
||||
import { NotificationsStore } from '../database';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
export class NotificationCleaner {
|
||||
private readonly retention: HumanDuration = { years: 1 };
|
||||
private readonly enabled: boolean = true;
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly scheduler: SchedulerService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly database: NotificationsStore,
|
||||
) {
|
||||
if (config.has('notifications.retention')) {
|
||||
const retentionConfig = config.get('notifications.retention');
|
||||
if (typeof retentionConfig === 'boolean' && !retentionConfig) {
|
||||
logger.info(
|
||||
'Notification retention is disabled, skipping notification cleaner task',
|
||||
);
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
this.retention = readDurationFromConfig(config, {
|
||||
key: 'notifications.retention',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initTaskRunner() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule: SchedulerServiceTaskScheduleDefinition = {
|
||||
frequency: { cron: '0 0 * * *' },
|
||||
timeout: { hours: 1 },
|
||||
initialDelay: { hours: 1 },
|
||||
scope: 'global',
|
||||
};
|
||||
|
||||
const taskRunner = this.scheduler.createScheduledTaskRunner(schedule);
|
||||
await taskRunner.run({
|
||||
id: 'notification-cleaner',
|
||||
fn: async () => {
|
||||
await this.clearNotifications(
|
||||
this.logger,
|
||||
this.database,
|
||||
this.retention,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async clearNotifications(
|
||||
logger: LoggerService,
|
||||
database: NotificationsStore,
|
||||
retention: HumanDuration,
|
||||
) {
|
||||
logger.info('Starting notification cleaner task');
|
||||
try {
|
||||
const result = await database.clearNotifications({ maxAge: retention });
|
||||
logger.info(
|
||||
`Notification cleaner task completed successfully, deleted ${result.deletedCount} notifications`,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ForwardedError('Notification cleaner task failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
|
||||
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
|
||||
import { DatabaseService } from '@backstage/backend-plugin-api';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { DatabaseNotificationsStore } from '../database';
|
||||
|
||||
const databases = TestDatabases.create();
|
||||
let store: DatabaseNotificationsStore;
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
@@ -83,6 +85,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await createDatabase(databaseId);
|
||||
store = await DatabaseNotificationsStore.create({
|
||||
database,
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /notifications', () => {
|
||||
@@ -93,7 +98,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
@@ -460,7 +465,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
@@ -550,7 +555,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
@@ -600,7 +605,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import {
|
||||
DatabaseNotificationsStore,
|
||||
normalizeSeverity,
|
||||
NotificationGetOptions,
|
||||
NotificationsStore,
|
||||
TopicGetOptions,
|
||||
} from '../database';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
UserInfoService,
|
||||
@@ -58,7 +57,7 @@ import pThrottle from 'p-throttle';
|
||||
export interface RouterOptions {
|
||||
logger: LoggerService;
|
||||
config: Config;
|
||||
database: DatabaseService;
|
||||
store: NotificationsStore;
|
||||
auth: AuthService;
|
||||
httpAuth: HttpAuthService;
|
||||
userInfo: UserInfoService;
|
||||
@@ -74,7 +73,7 @@ export async function createRouter(
|
||||
const {
|
||||
config,
|
||||
logger,
|
||||
database,
|
||||
store,
|
||||
auth,
|
||||
httpAuth,
|
||||
userInfo,
|
||||
@@ -84,7 +83,6 @@ export async function createRouter(
|
||||
} = options;
|
||||
|
||||
const WEB_NOTIFICATION_CHANNEL = 'Web';
|
||||
const store = await DatabaseNotificationsStore.create({ database });
|
||||
const frontendBaseUrl = config.getString('app.baseUrl');
|
||||
const concurrencyLimit =
|
||||
config.getOptionalNumber('notifications.concurrencyLimit') ?? 10;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@backstage/core-components';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { TemplateCardProps, TemplateCard } from '../TemplateCard';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsContext, IconComponent } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* The props for the {@link TemplateGroup} component.
|
||||
@@ -69,12 +69,18 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
|
||||
{titleComponent}
|
||||
<ItemCardGrid>
|
||||
{templates.map(({ template, additionalLinks }) => (
|
||||
<Card
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
entityRef: stringifyEntityRef(template),
|
||||
}}
|
||||
key={stringifyEntityRef(template)}
|
||||
additionalLinks={additionalLinks}
|
||||
template={template}
|
||||
onSelected={onSelected}
|
||||
/>
|
||||
>
|
||||
<Card
|
||||
additionalLinks={additionalLinks}
|
||||
template={template}
|
||||
onSelected={onSelected}
|
||||
/>
|
||||
</AnalyticsContext>
|
||||
))}
|
||||
</ItemCardGrid>
|
||||
</Content>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { ComponentType, useCallback } from 'react';
|
||||
import { ComponentType, useCallback, useEffect } from 'react';
|
||||
|
||||
import { TemplateGroup } from '../TemplateGroup/TemplateGroup';
|
||||
|
||||
@@ -58,12 +58,17 @@ export const TemplateGroups = (props: TemplateGroupsProps) => {
|
||||
[onTemplateSelected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [error, errorApi]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,13 +97,14 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
async (formState: Record<string, JsonValue>) => {
|
||||
await onCreate(formState);
|
||||
|
||||
const name =
|
||||
typeof formState.name === 'string' ? formState.name : undefined;
|
||||
analytics.captureEvent('create', name ?? templateName ?? 'unknown', {
|
||||
analytics.captureEvent('create', 'Task has been created', {
|
||||
value: minutesSaved,
|
||||
attributes: {
|
||||
templateSteps: sortedManifest?.steps?.length ?? 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onCreate, analytics, templateName, minutesSaved],
|
||||
[onCreate, analytics, minutesSaved, sortedManifest],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { useApi, featureFlagsApiRef } from '@backstage/core-plugin-api';
|
||||
import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Returns manifest of software templates with steps without a featureFlag tag.
|
||||
@@ -28,49 +29,51 @@ export const useFilteredSchemaProperties = (
|
||||
const featureFlagKey = 'backstage:featureFlag';
|
||||
const featureFlagApi = useApi(featureFlagsApiRef);
|
||||
|
||||
if (!manifest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filteredSteps = manifest?.steps
|
||||
.filter(step => {
|
||||
const featureFlag = step.schema[featureFlagKey];
|
||||
return (
|
||||
typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag)
|
||||
);
|
||||
})
|
||||
.map(step => {
|
||||
const filteredStep = cloneDeep(step);
|
||||
const removedPropertyKeys: Array<string> = [];
|
||||
if (filteredStep.schema.properties) {
|
||||
filteredStep.schema.properties = Object.fromEntries(
|
||||
Object.entries(filteredStep.schema.properties).filter(
|
||||
([key, value]) => {
|
||||
if (value[featureFlagKey]) {
|
||||
if (featureFlagApi.isActive(value[featureFlagKey])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
removedPropertyKeys.push(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
return useMemo(() => {
|
||||
if (!manifest) {
|
||||
return undefined;
|
||||
}
|
||||
const filteredSteps = manifest?.steps
|
||||
.filter(step => {
|
||||
const featureFlag = step.schema[featureFlagKey];
|
||||
return (
|
||||
typeof featureFlag !== 'string' ||
|
||||
featureFlagApi.isActive(featureFlag)
|
||||
);
|
||||
})
|
||||
.map(step => {
|
||||
const filteredStep = cloneDeep(step);
|
||||
const removedPropertyKeys: Array<string> = [];
|
||||
if (filteredStep.schema.properties) {
|
||||
filteredStep.schema.properties = Object.fromEntries(
|
||||
Object.entries(filteredStep.schema.properties).filter(
|
||||
([key, value]) => {
|
||||
if (value[featureFlagKey]) {
|
||||
if (featureFlagApi.isActive(value[featureFlagKey])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// remove the feature flag property key from required if they are not active
|
||||
filteredStep.schema.required = Array.isArray(
|
||||
filteredStep.schema.required,
|
||||
)
|
||||
? filteredStep.schema.required?.filter(
|
||||
r => !removedPropertyKeys.includes(r as string),
|
||||
)
|
||||
: filteredStep.schema.required;
|
||||
}
|
||||
removedPropertyKeys.push(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return filteredStep;
|
||||
});
|
||||
// remove the feature flag property key from required if they are not active
|
||||
filteredStep.schema.required = Array.isArray(
|
||||
filteredStep.schema.required,
|
||||
)
|
||||
? filteredStep.schema.required?.filter(
|
||||
r => !removedPropertyKeys.includes(r as string),
|
||||
)
|
||||
: filteredStep.schema.required;
|
||||
}
|
||||
|
||||
return { ...manifest, steps: filteredSteps };
|
||||
return filteredStep;
|
||||
});
|
||||
|
||||
return { ...manifest, steps: filteredSteps };
|
||||
}, [manifest, featureFlagApi]);
|
||||
};
|
||||
|
||||
+16
-15
@@ -21,7 +21,7 @@ import {
|
||||
renderInTestApp,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
ScaffolderApi,
|
||||
scaffolderApiRef,
|
||||
@@ -127,14 +127,10 @@ describe('TemplateWizardPage', () => {
|
||||
});
|
||||
|
||||
// Go to the final page
|
||||
await act(async () => {
|
||||
fireEvent.click(await findByRole('button', { name: 'Review' }));
|
||||
});
|
||||
fireEvent.click(await findByRole('button', { name: 'Review' }));
|
||||
|
||||
// Create the software
|
||||
await act(async () => {
|
||||
fireEvent.click(await findByRole('button', { name: 'Create' }));
|
||||
});
|
||||
fireEvent.click(await findByRole('button', { name: 'Create' }));
|
||||
|
||||
// The "Next Step" button should have fired an event
|
||||
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
|
||||
@@ -148,15 +144,20 @@ describe('TemplateWizardPage', () => {
|
||||
);
|
||||
|
||||
// And the "Create" button should have fired an event
|
||||
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'create',
|
||||
subject: 'expected-name',
|
||||
context: expect.objectContaining({
|
||||
entityRef: 'template:default/test',
|
||||
await waitFor(() =>
|
||||
expect(analyticsApi.captureEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'create',
|
||||
subject: 'Task has been created',
|
||||
attributes: {
|
||||
templateSteps: 1,
|
||||
},
|
||||
context: expect.objectContaining({
|
||||
entityRef: 'template:default/test',
|
||||
}),
|
||||
value: 120,
|
||||
}),
|
||||
value: 120,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@ import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import { SyntheticEvent, useState } from 'react';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { scaffolderTranslationRef } from '../../../translation';
|
||||
import { Link } from '@backstage/core-components';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
button: {
|
||||
@@ -82,7 +83,7 @@ export function TemplateWizardPageContextMenu(
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => window.open(editUrl, '_blank')}>
|
||||
<MenuItem component={Link} to={editUrl}>
|
||||
<ListItemIcon>
|
||||
<Edit fontSize="small" />
|
||||
</ListItemIcon>
|
||||
|
||||
@@ -21,7 +21,6 @@ import MenuItem from '@material-ui/core/MenuItem';
|
||||
import MenuList from '@material-ui/core/MenuList';
|
||||
import Popover from '@material-ui/core/Popover';
|
||||
import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
|
||||
import { useAsync } from '@react-hookz/web';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import Repeat from '@material-ui/icons/Repeat';
|
||||
import Replay from '@material-ui/icons/Replay';
|
||||
@@ -29,11 +28,8 @@ import Toc from '@material-ui/icons/Toc';
|
||||
import ControlPointIcon from '@material-ui/icons/ControlPoint';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import { SyntheticEvent, useState } from 'react';
|
||||
import { useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskReadPermission,
|
||||
taskCreatePermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
@@ -50,7 +46,8 @@ type ContextMenuProps = {
|
||||
onStartOver?: () => void;
|
||||
onToggleLogs?: (state: boolean) => void;
|
||||
onToggleButtonBar?: (state: boolean) => void;
|
||||
taskId?: string;
|
||||
isCancelButtonDisabled: boolean;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme, { fontColor: string }>(() => ({
|
||||
@@ -70,27 +67,13 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
onStartOver,
|
||||
onToggleLogs,
|
||||
onToggleButtonBar,
|
||||
taskId,
|
||||
} = props;
|
||||
const { getPageTheme } = useTheme();
|
||||
const pageTheme = getPageTheme({ themeId: 'website' });
|
||||
const classes = useStyles({ fontColor: pageTheme.fontColor });
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const analytics = useAnalytics();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const [{ status: cancelStatus }, { execute: cancel }] = useAsync(async () => {
|
||||
if (taskId) {
|
||||
analytics.captureEvent('cancelled', 'Template has been cancelled');
|
||||
await scaffolderApi.cancelTask(taskId);
|
||||
}
|
||||
});
|
||||
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
});
|
||||
@@ -171,12 +154,8 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={cancel}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
cancelStatus !== 'not-executed' ||
|
||||
!canCancelTask
|
||||
}
|
||||
onClick={props.onCancel}
|
||||
disabled={props.isCancelButtonDisabled}
|
||||
data-testid="cancel-task"
|
||||
>
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -32,7 +32,12 @@ import {
|
||||
useTaskEventStream,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { selectedTemplateRouteRef } from '../../routes';
|
||||
import { useAnalytics, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnalyticsContext,
|
||||
useAnalytics,
|
||||
useApi,
|
||||
useRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import qs from 'qs';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import {
|
||||
@@ -51,6 +56,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { scaffolderTranslationRef } from '../../translation';
|
||||
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { default as reactUseAsync } from 'react-use/esm/useAsync';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -82,6 +88,36 @@ export const OngoingTask = (props: {
|
||||
}>;
|
||||
}) => {
|
||||
// todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful.
|
||||
const { taskId } = useParams();
|
||||
const taskStream = useTaskEventStream(taskId!);
|
||||
const { namespace, name } =
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
entityRef:
|
||||
name &&
|
||||
stringifyEntityRef({
|
||||
kind: 'template',
|
||||
namespace,
|
||||
name,
|
||||
}),
|
||||
taskId,
|
||||
}}
|
||||
>
|
||||
<Page themeId="website">
|
||||
<OngoingTaskContent {...props} />
|
||||
</Page>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
function OngoingTaskContent(props: {
|
||||
TemplateOutputsComponent?: ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
}) {
|
||||
const { taskId } = useParams();
|
||||
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
@@ -183,7 +219,7 @@ export const OngoingTask = (props: {
|
||||
templateRouteRef,
|
||||
]);
|
||||
|
||||
const [{ status: _ }, { execute: triggerRetry }] = useAsync(async () => {
|
||||
const [, { execute: triggerRetry }] = useAsync(async () => {
|
||||
if (taskId) {
|
||||
analytics.captureEvent('retried', 'Template has been retried');
|
||||
await scaffolderApi.retry?.(taskId);
|
||||
@@ -202,9 +238,11 @@ export const OngoingTask = (props: {
|
||||
const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs;
|
||||
|
||||
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
|
||||
const isCancelButtonDisabled =
|
||||
!cancelEnabled || cancelStatus !== 'not-executed' || !canCancelTask;
|
||||
|
||||
return (
|
||||
<Page themeId="website">
|
||||
<>
|
||||
<Header
|
||||
pageTitleOverride={
|
||||
presentation
|
||||
@@ -231,7 +269,8 @@ export const OngoingTask = (props: {
|
||||
onRetry={triggerRetry}
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onToggleButtonBar={setButtonBarVisibleState}
|
||||
taskId={taskId}
|
||||
onCancel={triggerCancel}
|
||||
isCancelButtonDisabled={isCancelButtonDisabled}
|
||||
/>
|
||||
</Header>
|
||||
<Content className={classes.contentWrapper}>
|
||||
@@ -316,6 +355,6 @@ export const OngoingTask = (props: {
|
||||
</Paper>
|
||||
) : null}
|
||||
</Content>
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3602,6 +3602,7 @@ __metadata:
|
||||
cron: "npm:^3.0.0"
|
||||
express: "npm:^4.17.1"
|
||||
express-promise-router: "npm:^4.1.0"
|
||||
express-rate-limit: "npm:^7.5.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
git-url-parse: "npm:^15.0.0"
|
||||
helmet: "npm:^6.0.0"
|
||||
@@ -3624,6 +3625,7 @@ __metadata:
|
||||
pg: "npm:^8.11.3"
|
||||
pg-connection-string: "npm:^2.3.0"
|
||||
pg-format: "npm:^1.0.4"
|
||||
rate-limit-redis: "npm:^4.2.0"
|
||||
raw-body: "npm:^2.4.1"
|
||||
selfsigned: "npm:^2.0.0"
|
||||
supertest: "npm:^7.0.0"
|
||||
@@ -9667,9 +9669,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/aix-ppc64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/aix-ppc64@npm:0.25.4"
|
||||
"@esbuild/aix-ppc64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/aix-ppc64@npm:0.25.5"
|
||||
conditions: os=aix & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9681,9 +9683,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/android-arm64@npm:0.25.4"
|
||||
"@esbuild/android-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/android-arm64@npm:0.25.5"
|
||||
conditions: os=android & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9695,9 +9697,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/android-arm@npm:0.25.4"
|
||||
"@esbuild/android-arm@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/android-arm@npm:0.25.5"
|
||||
conditions: os=android & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9709,9 +9711,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/android-x64@npm:0.25.4"
|
||||
"@esbuild/android-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/android-x64@npm:0.25.5"
|
||||
conditions: os=android & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9723,9 +9725,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/darwin-arm64@npm:0.25.4"
|
||||
"@esbuild/darwin-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/darwin-arm64@npm:0.25.5"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9737,9 +9739,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/darwin-x64@npm:0.25.4"
|
||||
"@esbuild/darwin-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/darwin-x64@npm:0.25.5"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9751,9 +9753,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/freebsd-arm64@npm:0.25.4"
|
||||
"@esbuild/freebsd-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/freebsd-arm64@npm:0.25.5"
|
||||
conditions: os=freebsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9765,9 +9767,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/freebsd-x64@npm:0.25.4"
|
||||
"@esbuild/freebsd-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/freebsd-x64@npm:0.25.5"
|
||||
conditions: os=freebsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9779,9 +9781,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-arm64@npm:0.25.4"
|
||||
"@esbuild/linux-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-arm64@npm:0.25.5"
|
||||
conditions: os=linux & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9793,9 +9795,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-arm@npm:0.25.4"
|
||||
"@esbuild/linux-arm@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-arm@npm:0.25.5"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9807,9 +9809,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ia32@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-ia32@npm:0.25.4"
|
||||
"@esbuild/linux-ia32@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-ia32@npm:0.25.5"
|
||||
conditions: os=linux & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9821,9 +9823,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-loong64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-loong64@npm:0.25.4"
|
||||
"@esbuild/linux-loong64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-loong64@npm:0.25.5"
|
||||
conditions: os=linux & cpu=loong64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9835,9 +9837,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-mips64el@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-mips64el@npm:0.25.4"
|
||||
"@esbuild/linux-mips64el@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-mips64el@npm:0.25.5"
|
||||
conditions: os=linux & cpu=mips64el
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9849,9 +9851,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ppc64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-ppc64@npm:0.25.4"
|
||||
"@esbuild/linux-ppc64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-ppc64@npm:0.25.5"
|
||||
conditions: os=linux & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9863,9 +9865,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-riscv64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-riscv64@npm:0.25.4"
|
||||
"@esbuild/linux-riscv64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-riscv64@npm:0.25.5"
|
||||
conditions: os=linux & cpu=riscv64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9877,9 +9879,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-s390x@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-s390x@npm:0.25.4"
|
||||
"@esbuild/linux-s390x@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-s390x@npm:0.25.5"
|
||||
conditions: os=linux & cpu=s390x
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9891,16 +9893,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/linux-x64@npm:0.25.4"
|
||||
"@esbuild/linux-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/linux-x64@npm:0.25.5"
|
||||
conditions: os=linux & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/netbsd-arm64@npm:0.25.4"
|
||||
"@esbuild/netbsd-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/netbsd-arm64@npm:0.25.5"
|
||||
conditions: os=netbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9912,16 +9914,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/netbsd-x64@npm:0.25.4"
|
||||
"@esbuild/netbsd-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/netbsd-x64@npm:0.25.5"
|
||||
conditions: os=netbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/openbsd-arm64@npm:0.25.4"
|
||||
"@esbuild/openbsd-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/openbsd-arm64@npm:0.25.5"
|
||||
conditions: os=openbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9933,9 +9935,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/openbsd-x64@npm:0.25.4"
|
||||
"@esbuild/openbsd-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/openbsd-x64@npm:0.25.5"
|
||||
conditions: os=openbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9947,9 +9949,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/sunos-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/sunos-x64@npm:0.25.4"
|
||||
"@esbuild/sunos-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/sunos-x64@npm:0.25.5"
|
||||
conditions: os=sunos & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9961,9 +9963,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-arm64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/win32-arm64@npm:0.25.4"
|
||||
"@esbuild/win32-arm64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/win32-arm64@npm:0.25.5"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9975,9 +9977,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-ia32@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/win32-ia32@npm:0.25.4"
|
||||
"@esbuild/win32-ia32@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/win32-ia32@npm:0.25.5"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -9989,9 +9991,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-x64@npm:0.25.4":
|
||||
version: 0.25.4
|
||||
resolution: "@esbuild/win32-x64@npm:0.25.4"
|
||||
"@esbuild/win32-x64@npm:0.25.5":
|
||||
version: 0.25.5
|
||||
resolution: "@esbuild/win32-x64@npm:0.25.5"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -12414,9 +12416,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mswjs/interceptors@npm:^0.37.0":
|
||||
version: 0.37.1
|
||||
resolution: "@mswjs/interceptors@npm:0.37.1"
|
||||
"@mswjs/interceptors@npm:^0.39.1":
|
||||
version: 0.39.2
|
||||
resolution: "@mswjs/interceptors@npm:0.39.2"
|
||||
dependencies:
|
||||
"@open-draft/deferred-promise": "npm:^2.2.0"
|
||||
"@open-draft/logger": "npm:^0.3.0"
|
||||
@@ -12424,7 +12426,7 @@ __metadata:
|
||||
is-node-process: "npm:^1.2.0"
|
||||
outvariant: "npm:^1.4.3"
|
||||
strict-event-emitter: "npm:^0.5.1"
|
||||
checksum: 10/332d8aa50beb4834ccbda6a800ca00b1204adc0eba23e1c1f7bb9f4e564a92707e563f7a2424d4a8607404ec91424e5d8c34a87c250b191ca7b24dff12eba2c5
|
||||
checksum: 10/faaa95d636363a197f125c32066457fa74d5063d8ccae4c9c0e0510179060d92b1faf8640df45a0623e0bf42a30d610c83364a58e0eb0ca412c87b2e835936c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13381,10 +13383,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/openapi-types@npm:^22.2.0":
|
||||
version: 22.2.0
|
||||
resolution: "@octokit/openapi-types@npm:22.2.0"
|
||||
checksum: 10/0471b0c789fada5aa2390e6f82ba477738228ef7d2d986dda9aab0cb625d1562bd178ba0ba4d2655ce841079cd5efff9e58ece2077c27e569ea22109ea301830
|
||||
"@octokit/openapi-types@npm:^24.2.0":
|
||||
version: 24.2.0
|
||||
resolution: "@octokit/openapi-types@npm:24.2.0"
|
||||
checksum: 10/000897ebc6e247c2591049d6081e95eb5636f73798dadd695ee6048496772b58065df88823e74a760201828545a7ac601dd3c1bcd2e00079a62a9ee9d389409c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13397,14 +13399,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/plugin-paginate-rest@npm:11.3.1":
|
||||
version: 11.3.1
|
||||
resolution: "@octokit/plugin-paginate-rest@npm:11.3.1"
|
||||
"@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2":
|
||||
version: 11.4.4-cjs.2
|
||||
resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2"
|
||||
dependencies:
|
||||
"@octokit/types": "npm:^13.5.0"
|
||||
"@octokit/types": "npm:^13.7.0"
|
||||
peerDependencies:
|
||||
"@octokit/core": 5
|
||||
checksum: 10/82f5bcc3a536a44bed0a205c8301176c0d210b7a1c6d035a79b31a102e2e02f46234a38629cc984a21be544194ac69151814e9a909416aa7389cdffd1297bcd9
|
||||
checksum: 10/e0f696b3b69febe4e7c736d909065871f38bb8346a07f19a9c83246a02972568ac672667db472f846baef20a9611adf26ce8f0f189a11004c4b6618765078e19
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13440,14 +13442,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods@npm:13.2.2":
|
||||
version: 13.2.2
|
||||
resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.2.2"
|
||||
"@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1":
|
||||
version: 13.3.2-cjs.1
|
||||
resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1"
|
||||
dependencies:
|
||||
"@octokit/types": "npm:^13.5.0"
|
||||
"@octokit/types": "npm:^13.8.0"
|
||||
peerDependencies:
|
||||
"@octokit/core": ^5
|
||||
checksum: 10/9eccc1a22aa0b65f3f9378f26a74c386683db420c33202998918df1eef492e93212e1849e1d85530f425602663cfc2bfbf385a30991b8a04470334c74ba2386b
|
||||
checksum: 10/479827e62466e55bc1a50129d51597807bddc6c909e56be9e8dd9c1a91efa0f466a2f56b7d80438649e21ab0a3a195f840b3fccf2ae7f11fb0a919db8e62bc62
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13589,12 +13591,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.5.0":
|
||||
version: 13.6.2
|
||||
resolution: "@octokit/types@npm:13.6.2"
|
||||
"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.7.0, @octokit/types@npm:^13.8.0":
|
||||
version: 13.10.0
|
||||
resolution: "@octokit/types@npm:13.10.0"
|
||||
dependencies:
|
||||
"@octokit/openapi-types": "npm:^22.2.0"
|
||||
checksum: 10/8e614796f3554d28dfb77c570e80ef52d68ef311bdd4614ec263f8ea2266b9c06d4f7963fe2989f32cbfe4ea0c05e13eba9a64a6e0f64afb997cd975af154d52
|
||||
"@octokit/openapi-types": "npm:^24.2.0"
|
||||
checksum: 10/32f8f5010d7faae128b0cdd0c221f0ca8c3781fe44483ecd87162b3da507db667f7369acda81340f6e2c9c374d9a938803409c6085c2c01d98210b6c58efb99a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13641,10 +13643,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/webhooks-methods@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@octokit/webhooks-methods@npm:4.0.0"
|
||||
checksum: 10/f26892ed868488bf08d5be1fdacbc51f5b6ba84cef21067e0b1ff969d087202989e74303049691a77c75bb1940e7835ad6522b0f4f151ceef015e6d083789c80
|
||||
"@octokit/webhooks-methods@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "@octokit/webhooks-methods@npm:4.1.0"
|
||||
checksum: 10/a95ad68600c43798b09ea29d5a356fb69de25b45d38fbddf0ade00aadb0492b1d59985031e072a66d12e91034999a536653e5d2d4d01350d38cccf735d9ca270
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13655,10 +13657,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/webhooks-types@npm:7.1.0":
|
||||
version: 7.1.0
|
||||
resolution: "@octokit/webhooks-types@npm:7.1.0"
|
||||
checksum: 10/80b41945586243df9178a24dce9a5c4b2784eb963c5f6a1c76bcf5600a56e9cb51d8dc4a0da2108abfab3323cde577474a6365615fa1dfaac84c14e9295b54f9
|
||||
"@octokit/webhooks-types@npm:7.6.1":
|
||||
version: 7.6.1
|
||||
resolution: "@octokit/webhooks-types@npm:7.6.1"
|
||||
checksum: 10/0b11bd7e8d13b5a9cf14214421298a423d0180a5e1aaaea876ee4db6f97b5cca536f48d89af63105db75419d777a2402733eb0e110002d4dd59581ef36037bdc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13674,15 +13676,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@octokit/webhooks@npm:^12.0.4":
|
||||
version: 12.0.10
|
||||
resolution: "@octokit/webhooks@npm:12.0.10"
|
||||
"@octokit/webhooks@npm:^12.0.4, @octokit/webhooks@npm:^12.3.1":
|
||||
version: 12.3.1
|
||||
resolution: "@octokit/webhooks@npm:12.3.1"
|
||||
dependencies:
|
||||
"@octokit/request-error": "npm:^5.0.0"
|
||||
"@octokit/webhooks-methods": "npm:^4.0.0"
|
||||
"@octokit/webhooks-types": "npm:7.1.0"
|
||||
"@octokit/webhooks-methods": "npm:^4.1.0"
|
||||
"@octokit/webhooks-types": "npm:7.6.1"
|
||||
aggregate-error: "npm:^3.1.0"
|
||||
checksum: 10/ab7d216d1a1fae91bc3f75057c093707c1f6dbd5ff2106656154a7f471b1a1b5b78c947474e21362590e5510516f9537c4ab79ad6780cc91bfaaaa685aa366e0
|
||||
checksum: 10/373266807eb8dcf8d8c6f4685594106f1a257798a1b391cbe36e5b5faf60aca38a0467acd1356427ea7375edc111c03be3d443bc04efb16019a84e853c8a1bc3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15136,13 +15138,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:^1.32.3":
|
||||
version: 1.52.0
|
||||
resolution: "@playwright/test@npm:1.52.0"
|
||||
version: 1.53.0
|
||||
resolution: "@playwright/test@npm:1.53.0"
|
||||
dependencies:
|
||||
playwright: "npm:1.52.0"
|
||||
playwright: "npm:1.53.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88
|
||||
checksum: 10/968df4fba133dd18b8c65504c3cc5a3a6071e49f0706c6524711cdfab321a51debfeb506b9ff0a8f7dd8ce3015921d82fa51429d8f11d392cc68de1938703c33
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -22439,9 +22441,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3":
|
||||
version: 15.7.14
|
||||
resolution: "@types/prop-types@npm:15.7.14"
|
||||
checksum: 10/d0c5407b9ccc3dd5fae0ccf9b1007e7622ba5e6f1c18399b4f24dff33619d469da4b9fa918a374f19dc0d9fe6a013362aab0b844b606cfc10676efba3f5f736d
|
||||
version: 15.7.15
|
||||
resolution: "@types/prop-types@npm:15.7.15"
|
||||
checksum: 10/31aa2f59b28f24da6fb4f1d70807dae2aedfce090ec63eaf9ea01727a9533ef6eaf017de5bff99fbccad7d1c9e644f52c6c2ba30869465dd22b1a7221c29f356
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -24036,7 +24038,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0":
|
||||
"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0, @yarnpkg/core@npm:^4.4.1":
|
||||
version: 4.4.1
|
||||
resolution: "@yarnpkg/core@npm:4.4.1"
|
||||
dependencies:
|
||||
@@ -25748,7 +25750,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:1.9.0, axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8":
|
||||
"axios@npm:1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "axios@npm:1.9.0"
|
||||
dependencies:
|
||||
@@ -25759,6 +25761,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8":
|
||||
version: 1.10.0
|
||||
resolution: "axios@npm:1.10.0"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.0"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10/d43c80316a45611fd395743e15d16ea69a95f2b7f7095f2bb12cb78f9ca0a905194a02e52a3bf4e0db9f85fd1186d6c690410644c10ecd8bb0a468e57c2040e4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axobject-query@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "axobject-query@npm:4.1.0"
|
||||
@@ -29468,8 +29481,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"dockerode@npm:^4.0.0":
|
||||
version: 4.0.6
|
||||
resolution: "dockerode@npm:4.0.6"
|
||||
version: 4.0.7
|
||||
resolution: "dockerode@npm:4.0.7"
|
||||
dependencies:
|
||||
"@balena/dockerignore": "npm:^1.0.2"
|
||||
"@grpc/grpc-js": "npm:^1.11.1"
|
||||
@@ -29478,7 +29491,7 @@ __metadata:
|
||||
protobufjs: "npm:^7.3.2"
|
||||
tar-fs: "npm:~2.1.2"
|
||||
uuid: "npm:^10.0.0"
|
||||
checksum: 10/75bd706f20f01742d22913b72e2a5215a4d9f79772c29079772f84fc41a2b1890a704a1aa3d1d764405367090e93c33197d6add19fde3546bac919d98eebc8e3
|
||||
checksum: 10/d7cd174cf4489f41335ec8aaaa7c98c164a624f9a793544aa5280d85254ce276e7797de896042ce47d87aca6f8d2653acc37a0d18807d4ce8ea31892faef40a8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -30327,34 +30340,34 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0, esbuild@npm:^0.25.0":
|
||||
version: 0.25.4
|
||||
resolution: "esbuild@npm:0.25.4"
|
||||
version: 0.25.5
|
||||
resolution: "esbuild@npm:0.25.5"
|
||||
dependencies:
|
||||
"@esbuild/aix-ppc64": "npm:0.25.4"
|
||||
"@esbuild/android-arm": "npm:0.25.4"
|
||||
"@esbuild/android-arm64": "npm:0.25.4"
|
||||
"@esbuild/android-x64": "npm:0.25.4"
|
||||
"@esbuild/darwin-arm64": "npm:0.25.4"
|
||||
"@esbuild/darwin-x64": "npm:0.25.4"
|
||||
"@esbuild/freebsd-arm64": "npm:0.25.4"
|
||||
"@esbuild/freebsd-x64": "npm:0.25.4"
|
||||
"@esbuild/linux-arm": "npm:0.25.4"
|
||||
"@esbuild/linux-arm64": "npm:0.25.4"
|
||||
"@esbuild/linux-ia32": "npm:0.25.4"
|
||||
"@esbuild/linux-loong64": "npm:0.25.4"
|
||||
"@esbuild/linux-mips64el": "npm:0.25.4"
|
||||
"@esbuild/linux-ppc64": "npm:0.25.4"
|
||||
"@esbuild/linux-riscv64": "npm:0.25.4"
|
||||
"@esbuild/linux-s390x": "npm:0.25.4"
|
||||
"@esbuild/linux-x64": "npm:0.25.4"
|
||||
"@esbuild/netbsd-arm64": "npm:0.25.4"
|
||||
"@esbuild/netbsd-x64": "npm:0.25.4"
|
||||
"@esbuild/openbsd-arm64": "npm:0.25.4"
|
||||
"@esbuild/openbsd-x64": "npm:0.25.4"
|
||||
"@esbuild/sunos-x64": "npm:0.25.4"
|
||||
"@esbuild/win32-arm64": "npm:0.25.4"
|
||||
"@esbuild/win32-ia32": "npm:0.25.4"
|
||||
"@esbuild/win32-x64": "npm:0.25.4"
|
||||
"@esbuild/aix-ppc64": "npm:0.25.5"
|
||||
"@esbuild/android-arm": "npm:0.25.5"
|
||||
"@esbuild/android-arm64": "npm:0.25.5"
|
||||
"@esbuild/android-x64": "npm:0.25.5"
|
||||
"@esbuild/darwin-arm64": "npm:0.25.5"
|
||||
"@esbuild/darwin-x64": "npm:0.25.5"
|
||||
"@esbuild/freebsd-arm64": "npm:0.25.5"
|
||||
"@esbuild/freebsd-x64": "npm:0.25.5"
|
||||
"@esbuild/linux-arm": "npm:0.25.5"
|
||||
"@esbuild/linux-arm64": "npm:0.25.5"
|
||||
"@esbuild/linux-ia32": "npm:0.25.5"
|
||||
"@esbuild/linux-loong64": "npm:0.25.5"
|
||||
"@esbuild/linux-mips64el": "npm:0.25.5"
|
||||
"@esbuild/linux-ppc64": "npm:0.25.5"
|
||||
"@esbuild/linux-riscv64": "npm:0.25.5"
|
||||
"@esbuild/linux-s390x": "npm:0.25.5"
|
||||
"@esbuild/linux-x64": "npm:0.25.5"
|
||||
"@esbuild/netbsd-arm64": "npm:0.25.5"
|
||||
"@esbuild/netbsd-x64": "npm:0.25.5"
|
||||
"@esbuild/openbsd-arm64": "npm:0.25.5"
|
||||
"@esbuild/openbsd-x64": "npm:0.25.5"
|
||||
"@esbuild/sunos-x64": "npm:0.25.5"
|
||||
"@esbuild/win32-arm64": "npm:0.25.5"
|
||||
"@esbuild/win32-ia32": "npm:0.25.5"
|
||||
"@esbuild/win32-x64": "npm:0.25.5"
|
||||
dependenciesMeta:
|
||||
"@esbuild/aix-ppc64":
|
||||
optional: true
|
||||
@@ -30408,7 +30421,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
esbuild: bin/esbuild
|
||||
checksum: 10/227ffe9b31f0b184a0b0a0210bb9d32b2b115b8c5c9b09f08db2c3928cb470fc55a22dbba3c2894365d3abcc62c2089b85638be96a20691d1234d31990ea01b2
|
||||
checksum: 10/0fa4c3b42c6ddf1a008e75a4bb3dcab08ce22ac0b31dd59dc01f7fe8e21380bfaec07a2fe3730a7cf430da5a30142d016714b358666325a4733547afa42be405
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -31437,6 +31450,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"express-rate-limit@npm:^7.5.0":
|
||||
version: 7.5.0
|
||||
resolution: "express-rate-limit@npm:7.5.0"
|
||||
peerDependencies:
|
||||
express: ^4.11 || 5 || ^5.0.0-beta.1
|
||||
checksum: 10/eff34c83bf586789933a332a339b66649e2cca95c8e977d193aa8bead577d3182ac9f0e9c26f39389287539b8038890ff023f910b54ebb506a26a2ce135b92ca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"express-session@npm:^1.17.1, express-session@npm:^1.17.3":
|
||||
version: 1.18.1
|
||||
resolution: "express-session@npm:1.18.1"
|
||||
@@ -39670,14 +39692,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"msw@npm:^2.0.0, msw@npm:^2.0.8":
|
||||
version: 2.8.2
|
||||
resolution: "msw@npm:2.8.2"
|
||||
version: 2.10.2
|
||||
resolution: "msw@npm:2.10.2"
|
||||
dependencies:
|
||||
"@bundled-es-modules/cookie": "npm:^2.0.1"
|
||||
"@bundled-es-modules/statuses": "npm:^1.0.1"
|
||||
"@bundled-es-modules/tough-cookie": "npm:^0.1.6"
|
||||
"@inquirer/confirm": "npm:^5.0.0"
|
||||
"@mswjs/interceptors": "npm:^0.37.0"
|
||||
"@mswjs/interceptors": "npm:^0.39.1"
|
||||
"@open-draft/deferred-promise": "npm:^2.2.0"
|
||||
"@open-draft/until": "npm:^2.1.0"
|
||||
"@types/cookie": "npm:^0.6.0"
|
||||
@@ -39698,7 +39720,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
msw: cli/index.js
|
||||
checksum: 10/7579a8dccb8cc8eb0f13d0bf3a232a3d50a478511d95bc2a4b70778e78ffa28fd0949a855fbceb6ec381bbf76f6331a6d36fcb4a3197ff5bf55d0f14a7f3b35c
|
||||
checksum: 10/bc90bc34a0b9e978e662f33fa630a0de66c4b6eff3a92b41efa08bf67d79b43e4a961a17b0d274580393c912f4ac6226c76a8bc33148799b1ee43477df712c26
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -40763,20 +40785,21 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"octokit@npm:^3.0.0":
|
||||
version: 3.2.1
|
||||
resolution: "octokit@npm:3.2.1"
|
||||
version: 3.2.2
|
||||
resolution: "octokit@npm:3.2.2"
|
||||
dependencies:
|
||||
"@octokit/app": "npm:^14.0.2"
|
||||
"@octokit/core": "npm:^5.0.0"
|
||||
"@octokit/oauth-app": "npm:^6.0.0"
|
||||
"@octokit/plugin-paginate-graphql": "npm:^4.0.0"
|
||||
"@octokit/plugin-paginate-rest": "npm:11.3.1"
|
||||
"@octokit/plugin-rest-endpoint-methods": "npm:13.2.2"
|
||||
"@octokit/plugin-paginate-rest": "npm:11.4.4-cjs.2"
|
||||
"@octokit/plugin-rest-endpoint-methods": "npm:13.3.2-cjs.1"
|
||||
"@octokit/plugin-retry": "npm:^6.0.0"
|
||||
"@octokit/plugin-throttling": "npm:^8.0.0"
|
||||
"@octokit/request-error": "npm:^5.0.0"
|
||||
"@octokit/types": "npm:^13.0.0"
|
||||
checksum: 10/a3539831c9c0828b1e37dc94a3668f5fb9aa873fc396c0464275d152e4065b6b20300cfba766871cb52ed2e6d537febda7d4be872894c4c62024393a3578fde0
|
||||
"@octokit/webhooks": "npm:^12.3.1"
|
||||
checksum: 10/a258cc62767552fcf9d9a2dfb2aac44b326a2a235fb6444c1f37bc018bda4ac4bf0203704d8b7b3955d3caee6f9256c200287f7e99843c8be01febe4d9fe86d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -42152,27 +42175,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "playwright-core@npm:1.52.0"
|
||||
"playwright-core@npm:1.53.0":
|
||||
version: 1.53.0
|
||||
resolution: "playwright-core@npm:1.53.0"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10/42e13f5f98dc25ebc95525fb338a215b9097b2ba39d41e99972a190bf75d79979f163f5bc07b1ca06847ee07acb2c9b487d070fab67e9cd55e33310fc05aca3c
|
||||
checksum: 10/881f27a9b7edd9954700489a5a4212cb91bcada226fd1d79a239b2eab0f333df1e2e41e275e6fa846d7f57c6a92afe14dca33ca7a2ce303dfb687d02511b7c69
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "playwright@npm:1.52.0"
|
||||
"playwright@npm:1.53.0":
|
||||
version: 1.53.0
|
||||
resolution: "playwright@npm:1.53.0"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.52.0"
|
||||
playwright-core: "npm:1.53.0"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec
|
||||
checksum: 10/0b0258630f39b4d6ff1555d008ee4d591fe45cbe1e0f643a612397e3e6b1f7a99a2037a957eaa7351edd907ba10966ba105b2d244eafd1b247378910b660f086
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -43414,6 +43437,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rate-limit-redis@npm:^4.2.0":
|
||||
version: 4.2.1
|
||||
resolution: "rate-limit-redis@npm:4.2.1"
|
||||
peerDependencies:
|
||||
express-rate-limit: ">= 6"
|
||||
checksum: 10/c36c50cfca992cbd14c97c08bb01c7d1d340d27b5fa1a2c1154275fec0b59c187eb6fb207da3d035c7c87e7629c92324cd53074f854dbb16548faf94f5a41351
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rate-limiter-flexible@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "rate-limiter-flexible@npm:4.0.1"
|
||||
@@ -50644,8 +50676,9 @@ __metadata:
|
||||
"@backstage/cli-common": "workspace:^"
|
||||
"@backstage/release-manifests": "workspace:^"
|
||||
"@yarnpkg/builder": "npm:^4.2.1"
|
||||
"@yarnpkg/core": "npm:^4.4.0"
|
||||
"@yarnpkg/core": "npm:^4.4.1"
|
||||
"@yarnpkg/fslib": "npm:^3.1.2"
|
||||
"@yarnpkg/plugin-essentials": "npm:^4.4.0"
|
||||
"@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch"
|
||||
"@yarnpkg/plugin-pack": "npm:^4.0.1"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
|
||||
Reference in New Issue
Block a user