Merge branch 'master' of github.com:spotify/backstage into mob/union-types
* 'master' of github.com:spotify/backstage: (22 commits) [microsite] Add CNCF logo (#2511) feat: use title for API type in table new tutorials (#2435) e2e-test: ignore unhandled errors from jsdom Techdocs without docker-in-docker (#2438) Test for docker before attempting to run a container (#2497) scaffolder: Fix backend fails to start feat: add api-docs config ref for widgets Mark docs template description as required Send component description to GitHub Pass props to lists and control rendering from outside wrapper Remove commented out code Remove unnecessary boolean conversion docs: improve navigation around ADRs Update default-app template Handle empty sections Change logic of FeatureFlags.toggle() Update sidepar Add featureflags components and toggle method on api Cleanup and breaking out into more components ...
This commit is contained in:
@@ -16,8 +16,8 @@
|
||||
|
||||
import { ComponentEntity, Entity } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import {
|
||||
ApiDefinitionCard,
|
||||
useComponentApiEntities,
|
||||
|
||||
@@ -15,15 +15,16 @@
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { CardTab, TabbedCard } from '@backstage/core';
|
||||
import { CardTab, useApi, TabbedCard } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget';
|
||||
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
|
||||
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
|
||||
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
|
||||
|
||||
type ApiDefinitionWidget = {
|
||||
export type ApiDefinitionWidget = {
|
||||
type: string;
|
||||
title: string;
|
||||
component: (definition: string) => React.ReactElement;
|
||||
@@ -61,34 +62,25 @@ export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
|
||||
|
||||
type Props = {
|
||||
apiEntity?: ApiEntity;
|
||||
definitionWidgets?: ApiDefinitionWidget[];
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
definitionWidgets: defaultDefinitionWidgets(),
|
||||
};
|
||||
|
||||
export const ApiDefinitionCard = (props: Props) => {
|
||||
const { apiEntity, definitionWidgets } = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
};
|
||||
export const ApiDefinitionCard = ({ apiEntity }: Props) => {
|
||||
const config = useApi(apiDocsConfigRef);
|
||||
const { getApiDefinitionWidget } = config;
|
||||
|
||||
if (!apiEntity) {
|
||||
return <Alert severity="error">Could not fetch the API</Alert>;
|
||||
}
|
||||
|
||||
const definitionWidget = definitionWidgets.find(
|
||||
d => d.type === apiEntity.spec.type,
|
||||
);
|
||||
const definitionWidget = getApiDefinitionWidget(apiEntity);
|
||||
|
||||
if (definitionWidget) {
|
||||
return (
|
||||
<TabbedCard title={apiEntity.metadata.name}>
|
||||
<CardTab label={definitionWidget.title}>
|
||||
<CardTab label={definitionWidget.title} key="widget">
|
||||
{definitionWidget.component(apiEntity.spec.definition)}
|
||||
</CardTab>
|
||||
<CardTab label="Raw">
|
||||
<CardTab label="Raw" key="raw">
|
||||
<PlainApiDefinitionWidget
|
||||
definition={apiEntity.spec.definition}
|
||||
language={definitionWidget.rawLanguage || apiEntity.spec.type}
|
||||
@@ -103,7 +95,7 @@ export const ApiDefinitionCard = (props: Props) => {
|
||||
title={apiEntity.metadata.name}
|
||||
children={[
|
||||
// Has to be an array, otherwise typescript doesn't like that this has only a single child
|
||||
<CardTab label={apiEntity.spec.type}>
|
||||
<CardTab label={apiEntity.spec.type} key="raw">
|
||||
<PlainApiDefinitionWidget
|
||||
definition={apiEntity.spec.definition}
|
||||
language={apiEntity.spec.type}
|
||||
|
||||
@@ -14,4 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ApiDefinitionCard } from './ApiDefinitionCard';
|
||||
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
|
||||
export {
|
||||
ApiDefinitionCard,
|
||||
defaultDefinitionWidgets,
|
||||
} from './ApiDefinitionCard';
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { ApiExplorerPage } from './ApiExplorerPage';
|
||||
|
||||
describe('ApiCatalogPage', () => {
|
||||
@@ -32,6 +33,7 @@ describe('ApiCatalogPage', () => {
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
spec: { type: 'openapi' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -39,12 +41,17 @@ describe('ApiCatalogPage', () => {
|
||||
metadata: {
|
||||
name: 'Entity2',
|
||||
},
|
||||
spec: { type: 'openapi' },
|
||||
},
|
||||
] as Entity[]),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
|
||||
const apiDocsConfig = {
|
||||
getApiDefinitionWidget: () => undefined,
|
||||
};
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
@@ -52,6 +59,7 @@ describe('ApiCatalogPage', () => {
|
||||
apis={ApiRegistry.from([
|
||||
[catalogApiRef, catalogApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
[apiDocsConfigRef, apiDocsConfig],
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { ApiExplorerTable } from './ApiExplorerTable';
|
||||
|
||||
const entites: Entity[] = [
|
||||
@@ -25,29 +27,38 @@ const entites: Entity[] = [
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'api1' },
|
||||
spec: { type: 'openapi' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'api2' },
|
||||
spec: { type: 'openapi' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'api3' },
|
||||
spec: { type: 'grpc' },
|
||||
},
|
||||
];
|
||||
|
||||
const apiRegistry = ApiRegistry.with(apiDocsConfigRef, {
|
||||
getApiDefinitionWidget: () => undefined,
|
||||
});
|
||||
|
||||
describe('ApiCatalogTable component', () => {
|
||||
it('should render error message when error is passed in props', async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiExplorerTable
|
||||
titlePreamble="APIs"
|
||||
entities={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
/>,
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<ApiExplorerTable
|
||||
titlePreamble="APIs"
|
||||
entities={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
const errorMessage = await rendered.findByText(
|
||||
@@ -59,11 +70,13 @@ describe('ApiCatalogTable component', () => {
|
||||
it('should display entity names when loading has finished and no error occurred', async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiExplorerTable
|
||||
titlePreamble="APIs"
|
||||
entities={entites}
|
||||
loading={false}
|
||||
/>,
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<ApiExplorerTable
|
||||
titlePreamble="APIs"
|
||||
entities={entites}
|
||||
loading={false}
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument();
|
||||
|
||||
@@ -14,14 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Link, Chip } from '@material-ui/core';
|
||||
import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn, useApi } from '@backstage/core';
|
||||
import { Chip, Link } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { apiDocsConfigRef } from '../../config';
|
||||
import { entityRoute } from '../../routes';
|
||||
|
||||
const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => {
|
||||
const config = useApi(apiDocsConfigRef);
|
||||
const definition = config.getApiDefinitionWidget(apiEntity);
|
||||
const type = definition ? definition.title : apiEntity.spec.type;
|
||||
|
||||
return <span>{type}</span>;
|
||||
};
|
||||
|
||||
const columns: TableColumn<Entity>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
@@ -54,8 +63,11 @@ const columns: TableColumn<Entity>[] = [
|
||||
field: 'spec.lifecycle',
|
||||
},
|
||||
{
|
||||
title: 'Type', // TODO: Resolve the type display name using the API from https://github.com/spotify/backstage/pull/2451
|
||||
title: 'Type',
|
||||
field: 'spec.type',
|
||||
render: (entity: Entity) => (
|
||||
<ApiTypeTitle apiEntity={entity as ApiEntityV1alpha1} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ApiDefinitionCard } from './ApiDefinitionCard';
|
||||
export type { ApiDefinitionWidget } from './ApiDefinitionCard';
|
||||
export {
|
||||
ApiDefinitionCard,
|
||||
defaultDefinitionWidgets,
|
||||
} from './ApiDefinitionCard';
|
||||
export { AsyncApiDefinitionWidget } from './AsyncApiDefinitionWidget';
|
||||
export { OpenApiDefinitionWidget } from './OpenApiDefinitionWidget';
|
||||
export { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { ApiDefinitionWidget } from './components';
|
||||
|
||||
export const apiDocsConfigRef = createApiRef<ApiDocsConfig>({
|
||||
id: 'plugin.api-docs.config',
|
||||
description: 'Used to configure api-docs widgets',
|
||||
});
|
||||
|
||||
export interface ApiDocsConfig {
|
||||
getApiDefinitionWidget: (
|
||||
apiEntity: ApiEntity,
|
||||
) => ApiDefinitionWidget | undefined;
|
||||
}
|
||||
@@ -14,13 +14,30 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { createApiFactory, createPlugin } from '@backstage/core';
|
||||
import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage';
|
||||
import { defaultDefinitionWidgets } from './components/ApiDefinitionCard';
|
||||
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
|
||||
import { entityRoute, rootRoute } from './routes';
|
||||
import { apiDocsConfigRef } from './config';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'api-docs',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: apiDocsConfigRef,
|
||||
deps: {},
|
||||
factory: () => {
|
||||
const definitionWidgets = defaultDefinitionWidgets();
|
||||
return {
|
||||
getApiDefinitionWidget: (apiEntity: ApiEntity) => {
|
||||
return definitionWidgets.find(d => d.type === apiEntity.spec.type);
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(rootRoute, ApiExplorerPage);
|
||||
router.addRoute(entityRoute, ApiEntityPage);
|
||||
|
||||
@@ -23,11 +23,13 @@ export const rootRoute = createRouteRef({
|
||||
path: '/api-docs',
|
||||
title: 'APIs',
|
||||
});
|
||||
|
||||
export const entityRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/api-docs/:optionalNamespaceAndName/',
|
||||
title: 'API',
|
||||
});
|
||||
|
||||
export const catalogRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '',
|
||||
|
||||
@@ -17,6 +17,7 @@ spec:
|
||||
schema:
|
||||
required:
|
||||
- component_id
|
||||
- description
|
||||
properties:
|
||||
component_id:
|
||||
title: Name
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('GitHub Publisher', () => {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'bob',
|
||||
description: 'description',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
@@ -154,6 +155,7 @@ describe('GitHub Publisher', () => {
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
description: 'description',
|
||||
name: 'test',
|
||||
private: false,
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>,
|
||||
) {
|
||||
const [owner, name] = values.storePath.split('/');
|
||||
const description = values.description as string;
|
||||
|
||||
const user = await this.client.users.getByUsername({ username: owner });
|
||||
|
||||
@@ -71,10 +72,12 @@ export class GithubPublisher implements PublisherBase {
|
||||
org: owner,
|
||||
private: this.repoVisibility !== 'public',
|
||||
visibility: this.repoVisibility,
|
||||
description,
|
||||
})
|
||||
: this.client.repos.createForAuthenticatedUser({
|
||||
name,
|
||||
private: this.repoVisibility === 'private',
|
||||
description,
|
||||
});
|
||||
|
||||
const { data } = await repoCreationPromise;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@backstage/config": "^0.1.1-alpha.21",
|
||||
"@types/dockerode": "^2.5.34",
|
||||
"@types/express": "^4.17.6",
|
||||
"command-exists-promise": "^2.0.2",
|
||||
"default-branch": "^1.0.8",
|
||||
"dockerode": "^3.2.1",
|
||||
"express": "^4.17.1",
|
||||
|
||||
@@ -51,6 +51,10 @@ describe('helpers', () => {
|
||||
jest
|
||||
.spyOn(mockDocker, 'run')
|
||||
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
|
||||
|
||||
jest
|
||||
.spyOn(mockDocker, 'ping')
|
||||
.mockResolvedValue(Buffer.from('OK', 'utf-8'));
|
||||
});
|
||||
|
||||
const imageName = 'spotify/techdocs';
|
||||
@@ -99,5 +103,39 @@ describe('helpers', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should ping docker to test availability', async () => {
|
||||
await runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
docsDir,
|
||||
resultDir,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
|
||||
expect(mockDocker.ping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('where docker is unavailable', () => {
|
||||
const dockerError = 'a docker error';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(mockDocker, 'ping').mockImplementationOnce(() => {
|
||||
throw new Error(dockerError);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw with a descriptive error message including the docker error message', async () => {
|
||||
await expect(
|
||||
runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
docsDir,
|
||||
resultDir,
|
||||
dockerClient: mockDocker,
|
||||
}),
|
||||
).rejects.toThrow(new RegExp(`.+: ${dockerError}`));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { Writable, PassThrough } from 'stream';
|
||||
import Docker from 'dockerode';
|
||||
import { SupportedGeneratorKey } from './types';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// TODO: Implement proper support for more generators.
|
||||
export function getGeneratorKey(entity: Entity): SupportedGeneratorKey {
|
||||
@@ -38,6 +39,13 @@ type RunDockerContainerOptions = {
|
||||
createOptions?: Docker.ContainerCreateOptions;
|
||||
};
|
||||
|
||||
export type RunCommandOptions = {
|
||||
command: string;
|
||||
args: string[];
|
||||
options: object;
|
||||
logStream?: Writable;
|
||||
};
|
||||
|
||||
export async function runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
@@ -47,6 +55,14 @@ export async function runDockerContainer({
|
||||
dockerClient,
|
||||
createOptions,
|
||||
}: RunDockerContainerOptions) {
|
||||
try {
|
||||
await dockerClient.ping();
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
dockerClient.pull(imageName, {}, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
@@ -88,3 +104,41 @@ export async function runDockerContainer({
|
||||
|
||||
return { error, statusCode };
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param options the options object
|
||||
* @param options.command the command to run
|
||||
* @param options.args the arguments to pass the command
|
||||
* @param options.options options used in spawn
|
||||
* @param options.logStream the log streamer to capture log messages
|
||||
*/
|
||||
export const runCommand = async ({
|
||||
command,
|
||||
args,
|
||||
options,
|
||||
logStream = new PassThrough(),
|
||||
}: RunCommandOptions) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const process = spawn(command, args, options);
|
||||
|
||||
process.stdout.on('data', stream => {
|
||||
logStream.write(stream);
|
||||
});
|
||||
|
||||
process.stderr.on('data', stream => {
|
||||
logStream.write(stream);
|
||||
});
|
||||
|
||||
process.on('error', error => {
|
||||
return reject(error);
|
||||
});
|
||||
|
||||
process.on('close', code => {
|
||||
if (code !== 0) {
|
||||
return reject(`Command ${command} failed, exit code: ${code}`);
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
GeneratorRunOptions,
|
||||
GeneratorRunResult,
|
||||
} from './types';
|
||||
import { runDockerContainer } from './helpers';
|
||||
import { runDockerContainer, runCommand } from './helpers';
|
||||
|
||||
const commandExists = require('command-exists-promise');
|
||||
|
||||
export class TechdocsGenerator implements GeneratorBase {
|
||||
private readonly logger: Logger;
|
||||
@@ -46,17 +48,29 @@ export class TechdocsGenerator implements GeneratorBase {
|
||||
);
|
||||
|
||||
try {
|
||||
await runDockerContainer({
|
||||
imageName: 'spotify/techdocs',
|
||||
args: ['build', '-d', '/result'],
|
||||
logStream,
|
||||
docsDir: directory,
|
||||
resultDir,
|
||||
dockerClient,
|
||||
});
|
||||
this.logger.info(
|
||||
`[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`,
|
||||
);
|
||||
const mkdocsInstalled = await commandExists('mkdocs');
|
||||
if (mkdocsInstalled) {
|
||||
await runCommand({
|
||||
command: 'mkdocs',
|
||||
args: ['build', '-d', resultDir, '-v'],
|
||||
options: {
|
||||
cwd: directory,
|
||||
},
|
||||
logStream,
|
||||
});
|
||||
} else {
|
||||
await runDockerContainer({
|
||||
imageName: 'spotify/techdocs',
|
||||
args: ['build', '-d', '/result'],
|
||||
logStream,
|
||||
docsDir: directory,
|
||||
resultDir,
|
||||
dockerClient,
|
||||
});
|
||||
this.logger.info(
|
||||
`[TechDocs]: Successfully generated docs from ${directory} into ${resultDir}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`[TechDocs]: Failed to generate docs from ${directory} into ${resultDir}`,
|
||||
|
||||
Reference in New Issue
Block a user