Dashboard Schema V2: Import Dashboard (#102375)
* WIP: Working import * wip * remove model * wip * working * remove console.logs * fix * remove * remove uid field as v2 spec doesn't have uid * clean up reducer * revert arrow func * support upload
This commit is contained in:
@@ -1643,6 +1643,27 @@ exports[`better eslint`] = {
|
||||
"public/app/features/dashboard-scene/utils/PanelModelCompatibilityWrapper.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/v2schema/ImportDashboardFormV2.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/v2schema/ImportDashboardOverviewV2.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "4"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "5"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "6"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "7"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "8"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
|
||||
],
|
||||
"public/app/features/dashboard-scene/v2schema/test-helpers.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
|
||||
@@ -64,7 +64,6 @@ LibraryPanelSpec: {
|
||||
id: number
|
||||
// Title for the library panel in the dashboard
|
||||
title: string
|
||||
|
||||
libraryPanel: LibraryPanelRef
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, FieldErrors, UseFormReturn } from 'react-hook-form';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { ExpressionDatasourceRef } from '@grafana/runtime/internal';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input } from '@grafana/ui';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
import { DashboardInputs, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||
import { validateTitle } from 'app/features/manage-dashboards/utils/validation';
|
||||
interface Props
|
||||
extends Pick<
|
||||
UseFormReturn<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>,
|
||||
'register' | 'control' | 'getValues' | 'watch'
|
||||
> {
|
||||
inputs: DashboardInputs;
|
||||
uidReset: boolean;
|
||||
errors: FieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>;
|
||||
onCancel: () => void;
|
||||
onUidReset: () => void;
|
||||
onSubmit: FormsOnSubmit<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>;
|
||||
}
|
||||
|
||||
export const ImportDashboardFormV2 = ({
|
||||
register,
|
||||
errors,
|
||||
control,
|
||||
inputs,
|
||||
getValues,
|
||||
uidReset,
|
||||
onUidReset,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
watch,
|
||||
}: Props) => {
|
||||
const [isSubmitted, setSubmitted] = useState(false);
|
||||
const [selectedDataSources, setSelectedDataSources] = useState<Record<string, { uid: string; type: string }>>({});
|
||||
/*
|
||||
This useEffect is needed for overwriting a dashboard. It
|
||||
submits the form even if there's validation errors on title or uid.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isSubmitted && (errors.dashboard?.title || errors.k8s?.name)) {
|
||||
const formValues = getValues();
|
||||
onSubmit({
|
||||
...formValues,
|
||||
dashboard: {
|
||||
...formValues.dashboard,
|
||||
title: formValues.dashboard.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [errors, getValues, isSubmitted, onSubmit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Trans i18nKey="manage-dashboards.import-dashboard-form.options">Options</Trans>
|
||||
<Field
|
||||
label={t('manage-dashboards.import-dashboard-form.label-name', 'Name')}
|
||||
invalid={!!errors.dashboard?.title}
|
||||
error={errors.dashboard?.title && errors.dashboard?.title.message}
|
||||
>
|
||||
<Input
|
||||
{...(register as any)('dashboard.title', {
|
||||
required: 'Name is required',
|
||||
validate: async (v: string) => await validateTitle(v, getValues().folderUid ?? ''),
|
||||
})}
|
||||
type="text"
|
||||
data-testid={selectors.components.ImportDashboardForm.name}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Folder">
|
||||
<Controller<any>
|
||||
render={({ field: { ref, value, onChange, ...field } }) => (
|
||||
<FolderPicker
|
||||
{...field}
|
||||
onChange={(uid, title) => {
|
||||
onChange(uid, title);
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
name="folderUid"
|
||||
control={control}
|
||||
/>
|
||||
</Field>
|
||||
{inputs.dataSources &&
|
||||
inputs.dataSources.map((input: DataSourceInput) => {
|
||||
if (input.pluginId === ExpressionDatasourceRef.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataSourceOption = `datasource-${input.pluginId}` as const;
|
||||
|
||||
return (
|
||||
<Field
|
||||
label={input.label}
|
||||
description={input.description}
|
||||
key={input.pluginId}
|
||||
invalid={!!errors[dataSourceOption]}
|
||||
error={errors[dataSourceOption] ? 'Please select a data source' : undefined}
|
||||
>
|
||||
<Controller<any>
|
||||
name={dataSourceOption}
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<DataSourcePicker
|
||||
{...field}
|
||||
noDefault={true}
|
||||
placeholder={input.info}
|
||||
pluginId={input.pluginId}
|
||||
current={selectedDataSources[input.pluginId]}
|
||||
onChange={(ds) => {
|
||||
field.onChange(ds);
|
||||
// Update our selected datasources map
|
||||
setSelectedDataSources((prev) => ({
|
||||
...prev,
|
||||
[input.pluginId]: {
|
||||
uid: ds.uid,
|
||||
type: ds.type,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
|
||||
<Stack>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid={selectors.components.ImportDashboardForm.submit}
|
||||
variant={getButtonVariant(errors)}
|
||||
onClick={() => {
|
||||
setSubmitted(true);
|
||||
}}
|
||||
>
|
||||
{getButtonText(errors)}
|
||||
</Button>
|
||||
<Button type="reset" variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getButtonVariant(
|
||||
errors: FormFieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
|
||||
) {
|
||||
return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'destructive' : 'primary';
|
||||
}
|
||||
|
||||
function getButtonText(
|
||||
errors: FormFieldErrors<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
|
||||
) {
|
||||
return errors && (errors.dashboard?.title || errors.k8s?.name) ? 'Import (Overwrite)' : 'Import';
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
|
||||
import { AnnotationQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import { Form } from 'app/core/components/Form/Form';
|
||||
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
|
||||
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
|
||||
import { clearLoadedDashboard } from 'app/features/manage-dashboards/state/actions';
|
||||
import { useDispatch, useSelector, StoreState } from 'app/types';
|
||||
|
||||
import { ImportDashboardFormV2 } from './ImportDashboardFormV2';
|
||||
|
||||
const IMPORT_FINISHED_EVENT_NAME = 'dashboard_import_imported';
|
||||
|
||||
export function ImportDashboardOverviewV2() {
|
||||
const [uidReset, setUidReset] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Get state from Redux store
|
||||
const searchObj = locationService.getSearchObject();
|
||||
const dashboard = useSelector((state: StoreState) => state.importDashboard.dashboard as DashboardV2Spec);
|
||||
const inputs = useSelector((state: StoreState) => state.importDashboard.inputs);
|
||||
const folder = searchObj.folderUid ? { uid: String(searchObj.folderUid) } : { uid: '' };
|
||||
|
||||
function onUidReset() {
|
||||
setUidReset(true);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
dispatch(clearLoadedDashboard());
|
||||
}
|
||||
|
||||
async function onSubmit(form: SaveDashboardCommand<DashboardV2Spec>) {
|
||||
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
|
||||
|
||||
const dashboardWithDataSources: DashboardV2Spec = {
|
||||
...dashboard,
|
||||
title: form.dashboard.title,
|
||||
annotations: dashboard.annotations?.map((annotation: AnnotationQueryKind) => {
|
||||
if (annotation.spec.datasource?.type) {
|
||||
const dsType = annotation.spec.datasource.type;
|
||||
if (form[`datasource-${dsType}` as keyof typeof form]) {
|
||||
const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string };
|
||||
return {
|
||||
...annotation,
|
||||
spec: {
|
||||
...annotation.spec,
|
||||
datasource: {
|
||||
uid: ds.uid,
|
||||
type: ds.type,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return annotation;
|
||||
}),
|
||||
variables: dashboard.variables?.map((variable) => {
|
||||
if (variable.kind === 'QueryVariable') {
|
||||
if (variable.spec.datasource?.type) {
|
||||
const dsType = variable.spec.datasource.type;
|
||||
if (form[`datasource-${dsType}` as keyof typeof form]) {
|
||||
const ds = form[`datasource-${dsType}` as keyof typeof form] as { uid: string; type: string };
|
||||
return {
|
||||
...variable,
|
||||
spec: {
|
||||
...variable.spec,
|
||||
datasource: {
|
||||
...variable.spec.datasource,
|
||||
uid: ds.uid,
|
||||
type: ds.type,
|
||||
},
|
||||
options: [],
|
||||
current: {
|
||||
text: '',
|
||||
value: '',
|
||||
},
|
||||
refresh: 'onDashboardLoad',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (variable.kind === 'DatasourceVariable') {
|
||||
return {
|
||||
...variable,
|
||||
spec: {
|
||||
...variable.spec,
|
||||
current: {
|
||||
text: '',
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return variable;
|
||||
}),
|
||||
elements: Object.fromEntries(
|
||||
Object.entries(dashboard.elements).map(([key, element]) => {
|
||||
if (element.kind === 'Panel') {
|
||||
const panel = { ...element.spec };
|
||||
if (panel.data?.kind === 'QueryGroup') {
|
||||
const newQueries = panel.data.spec.queries.map((query: any) => {
|
||||
if (query.kind === 'PanelQuery') {
|
||||
const queryType = query.spec.query?.kind;
|
||||
// Match datasource by query kind
|
||||
if (queryType && form[`datasource-${queryType}` as keyof typeof form]) {
|
||||
const ds = form[`datasource-${queryType}` as keyof typeof form] as { uid: string; type: string };
|
||||
return {
|
||||
...query,
|
||||
spec: {
|
||||
...query.spec,
|
||||
datasource: {
|
||||
uid: ds.uid,
|
||||
type: ds.type,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return query;
|
||||
});
|
||||
panel.data = {
|
||||
...panel.data,
|
||||
spec: {
|
||||
...panel.data.spec,
|
||||
queries: newQueries,
|
||||
},
|
||||
};
|
||||
}
|
||||
return [
|
||||
key,
|
||||
{
|
||||
kind: element.kind,
|
||||
spec: panel,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [key, element];
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
const result = await getDashboardAPI('v2').saveDashboard({
|
||||
...form,
|
||||
dashboard: dashboardWithDataSources,
|
||||
});
|
||||
|
||||
if (result.url) {
|
||||
const dashboardUrl = locationUtil.stripBaseFromUrl(result.url);
|
||||
locationService.push(dashboardUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form<SaveDashboardCommand<DashboardV2Spec> & { [key: `datasource-${string}`]: string }>
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={{ dashboard, k8s: { annotations: { 'grafana.app/folder': folder.uid } } }}
|
||||
validateOnMount
|
||||
validateOn="onChange"
|
||||
>
|
||||
{({ register, errors, control, watch, getValues }) => (
|
||||
<ImportDashboardFormV2
|
||||
register={register}
|
||||
inputs={inputs}
|
||||
errors={errors}
|
||||
control={control}
|
||||
getValues={getValues}
|
||||
uidReset={uidReset}
|
||||
onCancel={onCancel}
|
||||
onUidReset={onUidReset}
|
||||
onSubmit={onSubmit}
|
||||
watch={watch}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,12 +27,14 @@ import { Form } from 'app/core/components/Form/Form';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { cleanUpAction } from '../../core/actions/cleanUp';
|
||||
import { ImportDashboardOverviewV2 } from '../dashboard-scene/v2schema/ImportDashboardOverviewV2';
|
||||
|
||||
import { ImportDashboardOverview } from './components/ImportDashboardOverview';
|
||||
import { fetchGcomDashboard, importDashboardJson } from './state/actions';
|
||||
import { fetchGcomDashboard, importDashboardJson, importDashboardV2Json } from './state/actions';
|
||||
import { initialImportDashboardState } from './state/reducers';
|
||||
import { validateDashboardJson, validateGcomDashboard } from './utils/validation';
|
||||
|
||||
@@ -53,6 +55,7 @@ const JSON_PLACEHOLDER = `{
|
||||
|
||||
const mapStateToProps = (state: StoreState) => ({
|
||||
loadingState: state.importDashboard.state,
|
||||
dashboard: state.importDashboard.dashboard,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
@@ -88,7 +91,13 @@ class UnthemedDashboardImport extends PureComponent<Props> {
|
||||
});
|
||||
|
||||
try {
|
||||
this.props.importDashboardJson(JSON.parse(String(result)));
|
||||
const json = JSON.parse(String(result));
|
||||
|
||||
if (json.elements) {
|
||||
dispatch(importDashboardV2Json(json));
|
||||
return;
|
||||
}
|
||||
this.props.importDashboardJson(json);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
appEvents.emit(AppEvents.alertError, ['Import failed', 'JSON -> JS Serialization failed: ' + error.message]);
|
||||
@@ -102,7 +111,14 @@ class UnthemedDashboardImport extends PureComponent<Props> {
|
||||
import_source: 'json_pasted',
|
||||
});
|
||||
|
||||
this.props.importDashboardJson(JSON.parse(formData.dashboardJson));
|
||||
const dashboard = JSON.parse(formData.dashboardJson);
|
||||
|
||||
if (dashboard.elements) {
|
||||
dispatch(importDashboardV2Json(dashboard));
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.importDashboardJson(dashboard);
|
||||
};
|
||||
|
||||
getGcomDashboard = (formData: { gcomDashboard: string }) => {
|
||||
@@ -229,6 +245,19 @@ class UnthemedDashboardImport extends PureComponent<Props> {
|
||||
subTitle: 'Import dashboard from file or Grafana.com',
|
||||
};
|
||||
|
||||
getDashboardOverview() {
|
||||
const { loadingState, dashboard } = this.props;
|
||||
|
||||
if (loadingState === LoadingState.Done) {
|
||||
if (dashboard.elements) {
|
||||
return <ImportDashboardOverviewV2 />;
|
||||
}
|
||||
return <ImportDashboardOverview />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loadingState } = this.props;
|
||||
|
||||
@@ -243,7 +272,7 @@ class UnthemedDashboardImport extends PureComponent<Props> {
|
||||
</Stack>
|
||||
)}
|
||||
{[LoadingState.Error, LoadingState.NotStarted].includes(loadingState) && this.renderImportForm()}
|
||||
{loadingState === LoadingState.Done && <ImportDashboardOverview />}
|
||||
{this.getDashboardOverview()}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { getBackendSrv, getDataSourceSrv, isFetchError } from '@grafana/runtime';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types';
|
||||
|
||||
import {
|
||||
@@ -18,6 +20,7 @@ import { DashboardJson } from '../types';
|
||||
|
||||
import {
|
||||
clearDashboard,
|
||||
DataSourceInput,
|
||||
fetchDashboard,
|
||||
fetchFailed,
|
||||
ImportDashboardDTO,
|
||||
@@ -56,6 +59,13 @@ export function importDashboardJson(dashboard: any): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function importDashboardV2Json(dashboard: DashboardV2Spec): ThunkResult<void> {
|
||||
return async (dispatch) => {
|
||||
dispatch(setJsonDashboard(dashboard));
|
||||
dispatch(processV2Elements(dashboard));
|
||||
};
|
||||
}
|
||||
|
||||
const getNewLibraryPanelsByInput = (input: Input, state: ImportDashboardState): LibraryPanel[] | undefined => {
|
||||
return input?.usage?.libraryPanels?.filter((usageLibPanel) =>
|
||||
state.inputs.libraryPanels.some(
|
||||
@@ -142,6 +152,53 @@ function processElements(dashboardJson?: { __elements?: Record<string, LibraryEl
|
||||
};
|
||||
}
|
||||
|
||||
function processV2Elements(dashboard: DashboardV2Spec): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
const elements = dashboard.elements;
|
||||
// get elements from dashboard
|
||||
// each element can only be a panel
|
||||
const inputs: Record<string, DataSourceInput> = {};
|
||||
for (const element of Object.values(elements)) {
|
||||
if (element.kind !== 'Panel') {
|
||||
throw new Error('Only panels are currenlty supported in v2 dashboards');
|
||||
}
|
||||
|
||||
for (const query of element.spec.data.spec.queries) {
|
||||
const datasourceRef = query.spec.datasource;
|
||||
if (!datasourceRef) {
|
||||
let dataSourceInput: DataSourceInput | undefined;
|
||||
const dsType = query.spec.query.kind;
|
||||
const datasource = await getDatasourceSrv().get({ type: dsType });
|
||||
if (!datasource) {
|
||||
dataSourceInput = {
|
||||
name: dsType,
|
||||
label: dsType,
|
||||
info: `No data sources of type ${dsType} found`,
|
||||
value: '',
|
||||
type: InputType.DataSource,
|
||||
pluginId: dsType,
|
||||
};
|
||||
|
||||
inputs[dsType] = dataSourceInput;
|
||||
} else {
|
||||
dataSourceInput = {
|
||||
name: datasource.name,
|
||||
label: datasource.name,
|
||||
info: `Select a ${datasource.name} data source`,
|
||||
value: datasource.uid,
|
||||
type: InputType.DataSource,
|
||||
pluginId: datasource.meta?.id,
|
||||
};
|
||||
|
||||
inputs[datasource.meta?.id] = dataSourceInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch(setInputs(Object.values(inputs)));
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLibraryPanelInputs(dashboardJson?: {
|
||||
__elements?: Record<string, LibraryElementExport>;
|
||||
}): Promise<LibraryPanelInput[]> {
|
||||
|
||||
Reference in New Issue
Block a user