mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
DataLinks: Add internal links in table and allow custom query (#25613)
* Add internal links in table and with custom query * Add specific types for internal and external link * Change the datalink types to be more backward compatible * Refactor the link utils for explore * Add internal linking to table panels * Fix derived field condition * Prettify * Add and fix tests * Prettify * Fix imports and tests * Remove unused type * Update packages/grafana-data/src/types/explore.ts Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> * Update packages/grafana-data/src/types/explore.ts Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { fieldIndexComparer } from '../field/fieldComparers';
|
||||
|
||||
function convertTableToDataFrame(table: TableData): DataFrame {
|
||||
const fields = table.columns.map(c => {
|
||||
// TODO: should be Column but type does not exists there so not sure whats up here.
|
||||
const { text, type, ...disp } = c as any;
|
||||
return {
|
||||
name: text, // rename 'text' to the 'name' field
|
||||
|
||||
@@ -118,6 +118,7 @@ describe('applyFieldOverrides', () => {
|
||||
overrides: [],
|
||||
},
|
||||
replaceVariables: (value: any) => value,
|
||||
getDataSourceSettingsByUid: undefined as any,
|
||||
theme: {} as GrafanaTheme,
|
||||
fieldConfigRegistry: new FieldConfigOptionsRegistry(),
|
||||
});
|
||||
@@ -187,6 +188,7 @@ describe('applyFieldOverrides', () => {
|
||||
overrides: [],
|
||||
},
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
getDataSourceSettingsByUid: undefined as any,
|
||||
replaceVariables: v => v,
|
||||
theme: {} as GrafanaTheme,
|
||||
})[0];
|
||||
@@ -204,6 +206,7 @@ describe('applyFieldOverrides', () => {
|
||||
data: [f0], // the frame
|
||||
fieldConfig: src as FieldConfigSource, // defaults + overrides
|
||||
replaceVariables: (undefined as any) as InterpolateFunction,
|
||||
getDataSourceSettingsByUid: undefined as any,
|
||||
theme: (undefined as any) as GrafanaTheme,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
})[0];
|
||||
@@ -231,6 +234,7 @@ describe('applyFieldOverrides', () => {
|
||||
data: [f0], // the frame
|
||||
fieldConfig: src as FieldConfigSource, // defaults + overrides
|
||||
replaceVariables: (undefined as any) as InterpolateFunction,
|
||||
getDataSourceSettingsByUid: undefined as any,
|
||||
theme: (undefined as any) as GrafanaTheme,
|
||||
autoMinMax: true,
|
||||
})[0];
|
||||
@@ -478,11 +482,72 @@ describe('getLinksSupplier', () => {
|
||||
});
|
||||
|
||||
const replaceSpy = jest.fn();
|
||||
const supplier = getLinksSupplier(f0, f0.fields[0], {}, replaceSpy, { theme: {} as GrafanaTheme });
|
||||
const supplier = getLinksSupplier(
|
||||
f0,
|
||||
f0.fields[0],
|
||||
{},
|
||||
replaceSpy,
|
||||
// this is used only for internal links so isn't needed here
|
||||
() => ({} as any),
|
||||
{
|
||||
theme: {} as GrafanaTheme,
|
||||
}
|
||||
);
|
||||
supplier({});
|
||||
|
||||
expect(replaceSpy).toBeCalledTimes(2);
|
||||
expect(replaceSpy.mock.calls[0][0]).toEqual('url to be interpolated');
|
||||
expect(replaceSpy.mock.calls[1][0]).toEqual('title to be interpolated');
|
||||
});
|
||||
|
||||
it('handles internal links', () => {
|
||||
locationUtil.initialize({
|
||||
getConfig: () => ({ appSubUrl: '' } as any),
|
||||
buildParamsFromVariables: (() => {}) as any,
|
||||
getTimeRangeForUrl: (() => {}) as any,
|
||||
});
|
||||
|
||||
const f0 = new MutableDataFrame({
|
||||
name: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
values: [10, 20],
|
||||
config: {
|
||||
links: [
|
||||
{
|
||||
url: '',
|
||||
title: '',
|
||||
internal: {
|
||||
datasourceUid: '0',
|
||||
query: '12345',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const supplier = getLinksSupplier(
|
||||
f0,
|
||||
f0.fields[0],
|
||||
{},
|
||||
// We do not need to interpolate anything for this test
|
||||
(value, vars, format) => value,
|
||||
uid => ({ name: 'testDS' } as any),
|
||||
{ theme: {} as GrafanaTheme }
|
||||
);
|
||||
const links = supplier({ valueRowIndex: 0 });
|
||||
expect(links.length).toBe(1);
|
||||
expect(links[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'testDS',
|
||||
href:
|
||||
'/explore?left={"datasource":"testDS","queries":["12345"],"mode":"Metrics","ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}',
|
||||
onClick: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
ValueLinkConfig,
|
||||
GrafanaTheme,
|
||||
TimeZone,
|
||||
DataLink,
|
||||
DataSourceInstanceSettings,
|
||||
} from '../types';
|
||||
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
|
||||
import { FieldMatcher } from '../types/transformations';
|
||||
@@ -33,6 +35,7 @@ import { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
|
||||
import { formatLabels } from '../utils/labels';
|
||||
import { getFrameDisplayName, getFieldDisplayName } from './fieldState';
|
||||
import { getTimeField } from '../dataframe/processDataFrame';
|
||||
import { mapInternalLinkToExplore } from '../utils/dataLinks';
|
||||
|
||||
interface OverrideProps {
|
||||
match: FieldMatcher;
|
||||
@@ -129,6 +132,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
data: options.data!,
|
||||
dataFrameIndex: index,
|
||||
replaceVariables: options.replaceVariables,
|
||||
getDataSourceSettingsByUid: options.getDataSourceSettingsByUid,
|
||||
fieldConfigRegistry: fieldConfigRegistry,
|
||||
};
|
||||
|
||||
@@ -206,10 +210,17 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
});
|
||||
|
||||
// Attach data links supplier
|
||||
f.getLinks = getLinksSupplier(frame, f, fieldScopedVars, context.replaceVariables, {
|
||||
theme: options.theme,
|
||||
timeZone: options.timeZone,
|
||||
});
|
||||
f.getLinks = getLinksSupplier(
|
||||
frame,
|
||||
f,
|
||||
fieldScopedVars,
|
||||
context.replaceVariables,
|
||||
context.getDataSourceSettingsByUid,
|
||||
{
|
||||
theme: options.theme,
|
||||
timeZone: options.timeZone,
|
||||
}
|
||||
);
|
||||
|
||||
return f;
|
||||
});
|
||||
@@ -348,6 +359,7 @@ export const getLinksSupplier = (
|
||||
field: Field,
|
||||
fieldScopedVars: ScopedVars,
|
||||
replaceVariables: InterpolateFunction,
|
||||
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined,
|
||||
options: {
|
||||
theme: GrafanaTheme;
|
||||
timeZone?: TimeZone;
|
||||
@@ -359,20 +371,11 @@ export const getLinksSupplier = (
|
||||
const timeRangeUrl = locationUtil.getTimeRangeUrlParams();
|
||||
const { timeField } = getTimeField(frame);
|
||||
|
||||
return field.config.links.map(link => {
|
||||
let href = link.url;
|
||||
return field.config.links.map((link: DataLink) => {
|
||||
const variablesQuery = locationUtil.getVariablesUrlParams();
|
||||
let dataFrameVars = {};
|
||||
let valueVars = {};
|
||||
|
||||
const info: LinkModel<Field> = {
|
||||
href: locationUtil.assureBaseUrl(href.replace(/\n/g, '')),
|
||||
title: link.title || '',
|
||||
target: link.targetBlank ? '_blank' : undefined,
|
||||
origin: field,
|
||||
};
|
||||
|
||||
const variablesQuery = locationUtil.getVariablesUrlParams();
|
||||
|
||||
// We are not displaying reduction result
|
||||
if (config.valueRowIndex !== undefined && !isNaN(config.valueRowIndex)) {
|
||||
const fieldsProxy = getFieldDisplayValuesProxy(frame, config.valueRowIndex, options);
|
||||
@@ -419,10 +422,25 @@ export const getLinksSupplier = (
|
||||
},
|
||||
};
|
||||
|
||||
info.href = replaceVariables(info.href, variables);
|
||||
info.title = replaceVariables(info.title, variables);
|
||||
info.href = locationUtil.processUrl(info.href);
|
||||
if (link.internal) {
|
||||
// For internal links at the moment only destination is Explore.
|
||||
return mapInternalLinkToExplore(link, variables, {} as any, field, {
|
||||
replaceVariables,
|
||||
getDataSourceSettingsByUid,
|
||||
});
|
||||
} else {
|
||||
let href = locationUtil.assureBaseUrl(link.url.replace(/\n/g, ''));
|
||||
href = replaceVariables(href, variables);
|
||||
href = locationUtil.processUrl(href);
|
||||
|
||||
return info;
|
||||
const info: LinkModel<Field> = {
|
||||
href,
|
||||
title: replaceVariables(link.title || '', variables),
|
||||
target: link.targetBlank ? '_blank' : undefined,
|
||||
origin: field,
|
||||
};
|
||||
|
||||
return info;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('getFieldDisplayValuesProxy', () => {
|
||||
overrides: [],
|
||||
},
|
||||
replaceVariables: (val: string) => val,
|
||||
getDataSourceSettingsByUid: (val: string) => ({} as any),
|
||||
timeZone: 'utc',
|
||||
theme: {} as GrafanaTheme,
|
||||
autoMinMax: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScopedVars } from './ScopedVars';
|
||||
import { DataQuery } from './datasource';
|
||||
|
||||
/**
|
||||
* Callback info for DataLink click events
|
||||
@@ -10,10 +11,13 @@ export interface DataLinkClickEvent<T = any> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Link configuration. The values may contain variables that need to be
|
||||
* processed before running
|
||||
* Link configuration. The values may contain variables that need to be
|
||||
* processed before showing the link to user.
|
||||
*
|
||||
* TODO: <T extends DataQuery> is not strictly true for internal links as we do not need refId for example but all
|
||||
* data source defined queries extend this so this is more for documentation.
|
||||
*/
|
||||
export interface DataLink {
|
||||
export interface DataLink<T extends DataQuery = any> {
|
||||
title: string;
|
||||
targetBlank?: boolean;
|
||||
|
||||
@@ -28,16 +32,19 @@ export interface DataLink {
|
||||
// Not saved in JSON/DTO
|
||||
onClick?: (event: DataLinkClickEvent) => void;
|
||||
|
||||
// At the moment this is used for derived fields for metadata about internal linking.
|
||||
meta?: {
|
||||
datasourceUid?: string;
|
||||
// If dataLink represents internal link this has to be filled. Internal link is defined as a query in a particular
|
||||
// datas ource that we want to show to the user. Usually this results in a link to explore but can also lead to
|
||||
// more custom onClick behaviour if needed.
|
||||
internal?: {
|
||||
query: T;
|
||||
datasourceUid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LinkTarget = '_blank' | '_self' | undefined;
|
||||
|
||||
/**
|
||||
* Processed Link Model. The values are ready to use
|
||||
* Processed Link Model. The values are ready to use
|
||||
*/
|
||||
export interface LinkModel<T = any> {
|
||||
href: string;
|
||||
|
||||
22
packages/grafana-data/src/types/explore.ts
Normal file
22
packages/grafana-data/src/types/explore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ExploreMode } from './datasource';
|
||||
import { RawTimeRange } from './time';
|
||||
import { LogsDedupStrategy } from './logs';
|
||||
|
||||
/** @internal */
|
||||
export interface ExploreUrlState {
|
||||
datasource: string;
|
||||
queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
|
||||
mode: ExploreMode;
|
||||
range: RawTimeRange;
|
||||
ui: ExploreUIState;
|
||||
originPanelId?: number;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface ExploreUIState {
|
||||
showingTable: boolean;
|
||||
showingGraph: boolean;
|
||||
showingLogs: boolean;
|
||||
dedupStrategy?: LogsDedupStrategy;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { MatcherConfig, FieldConfig, Field, DataFrame, GrafanaTheme, TimeZone } from '../types';
|
||||
import {
|
||||
MatcherConfig,
|
||||
FieldConfig,
|
||||
Field,
|
||||
DataFrame,
|
||||
GrafanaTheme,
|
||||
TimeZone,
|
||||
DataSourceInstanceSettings,
|
||||
} from '../types';
|
||||
import { InterpolateFunction } from './panel';
|
||||
import { StandardEditorProps, FieldConfigOptionsRegistry, StandardEditorContext } from '../field';
|
||||
import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
|
||||
@@ -106,6 +114,7 @@ export interface ApplyFieldOverrideOptions {
|
||||
data?: DataFrame[];
|
||||
fieldConfig: FieldConfigSource;
|
||||
replaceVariables: InterpolateFunction;
|
||||
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
|
||||
theme: GrafanaTheme;
|
||||
timeZone?: TimeZone;
|
||||
autoMinMax?: boolean;
|
||||
|
||||
@@ -25,6 +25,7 @@ export * from './theme';
|
||||
export * from './orgs';
|
||||
export * from './flot';
|
||||
export * from './trace';
|
||||
export * from './explore';
|
||||
|
||||
import * as AppEvents from './appEvents';
|
||||
import { AppEvent } from './appEvents';
|
||||
|
||||
39
packages/grafana-data/src/utils/dataLinks.test.ts
Normal file
39
packages/grafana-data/src/utils/dataLinks.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { mapInternalLinkToExplore } from './dataLinks';
|
||||
import { FieldType } from '../types';
|
||||
import { ArrayVector } from '../vector';
|
||||
|
||||
describe('mapInternalLinkToExplore', () => {
|
||||
it('creates internal link', () => {
|
||||
const link = mapInternalLinkToExplore(
|
||||
{
|
||||
url: '',
|
||||
title: '',
|
||||
internal: {
|
||||
datasourceUid: 'uid',
|
||||
query: { query: '12344' },
|
||||
},
|
||||
},
|
||||
{},
|
||||
{} as any,
|
||||
{
|
||||
name: 'test',
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
values: new ArrayVector([2]),
|
||||
},
|
||||
{
|
||||
replaceVariables: val => val,
|
||||
getDataSourceSettingsByUid: uid => ({ name: 'testDS' } as any),
|
||||
}
|
||||
);
|
||||
|
||||
expect(link).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'testDS',
|
||||
href:
|
||||
'/explore?left={"datasource":"testDS","queries":[{"query":"12344"}],"mode":"Metrics","ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}',
|
||||
onClick: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,17 @@
|
||||
import {
|
||||
DataLink,
|
||||
DataQuery,
|
||||
DataSourceInstanceSettings,
|
||||
ExploreMode,
|
||||
Field,
|
||||
InterpolateFunction,
|
||||
LinkModel,
|
||||
ScopedVars,
|
||||
TimeRange,
|
||||
} from '../types';
|
||||
import { locationUtil } from './location';
|
||||
import { serializeStateToUrlParam } from './url';
|
||||
|
||||
export const DataLinkBuiltInVars = {
|
||||
keepTime: '__url_time_range',
|
||||
timeRangeFrom: '__from',
|
||||
@@ -12,3 +26,96 @@ export const DataLinkBuiltInVars = {
|
||||
// name of the calculation represented by the value
|
||||
valueCalc: '__value.calc',
|
||||
};
|
||||
|
||||
type Options = {
|
||||
onClickFn?: (options: { datasourceUid: string; query: any }) => void;
|
||||
replaceVariables: InterpolateFunction;
|
||||
getDataSourceSettingsByUid: (uid: string) => DataSourceInstanceSettings | undefined;
|
||||
};
|
||||
|
||||
export function mapInternalLinkToExplore(
|
||||
link: DataLink,
|
||||
scopedVars: ScopedVars,
|
||||
range: TimeRange,
|
||||
field: Field,
|
||||
options: Options
|
||||
): LinkModel<Field> {
|
||||
if (!link.internal) {
|
||||
throw new Error('Trying to map external link as internal');
|
||||
}
|
||||
const { onClickFn, replaceVariables, getDataSourceSettingsByUid } = options;
|
||||
|
||||
const interpolatedQuery = interpolateQuery(link, scopedVars, replaceVariables);
|
||||
return {
|
||||
title: link.title
|
||||
? replaceVariables(link.title || '', scopedVars)
|
||||
: getDataSourceSettingsByUid(link.internal.datasourceUid)?.name || 'Unknown datasource',
|
||||
|
||||
// In this case this is meant to be internal link (opens split view by default) the href will also points
|
||||
// to explore but this way you can open it in new tab.
|
||||
href: generateInternalHref(
|
||||
getDataSourceSettingsByUid(link.internal.datasourceUid)?.name || 'unknown',
|
||||
interpolatedQuery,
|
||||
range
|
||||
),
|
||||
onClick: onClickFn
|
||||
? () => {
|
||||
onClickFn?.({
|
||||
datasourceUid: link.internal!.datasourceUid,
|
||||
query: interpolatedQuery,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
target: '_self',
|
||||
origin: field,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates href for internal derived field link.
|
||||
*/
|
||||
function generateInternalHref<T extends DataQuery = any>(datasourceName: string, query: T, range: TimeRange): string {
|
||||
return locationUtil.assureBaseUrl(
|
||||
`/explore?left=${serializeStateToUrlParam({
|
||||
range: range.raw,
|
||||
datasource: datasourceName,
|
||||
queries: [query],
|
||||
// This should get overwritten if datasource does not support that mode and we do not know what mode is
|
||||
// preferred anyway.
|
||||
mode: ExploreMode.Metrics,
|
||||
ui: {
|
||||
showingGraph: true,
|
||||
showingTable: true,
|
||||
showingLogs: true,
|
||||
},
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
function interpolateQuery<T extends DataQuery = any>(
|
||||
link: DataLink,
|
||||
scopedVars: ScopedVars,
|
||||
replaceVariables: InterpolateFunction
|
||||
): T {
|
||||
let stringifiedQuery = '';
|
||||
try {
|
||||
stringifiedQuery = JSON.stringify(link.internal?.query || '');
|
||||
} catch (err) {
|
||||
// should not happen and not much to do about this, possibly something non stringifiable in the query
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Replace any variables inside the query. This may not be the safest as it can also replace keys etc so may not
|
||||
// actually work with every datasource query right now.
|
||||
stringifiedQuery = replaceVariables(stringifiedQuery, scopedVars);
|
||||
|
||||
let replacedQuery = {} as T;
|
||||
try {
|
||||
replacedQuery = JSON.parse(stringifiedQuery);
|
||||
} catch (err) {
|
||||
// again should not happen and not much to do about this, probably some issue with how we replaced the variables.
|
||||
console.error(stringifiedQuery, err);
|
||||
}
|
||||
|
||||
return replacedQuery;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
|
||||
*/
|
||||
|
||||
import { ExploreUrlState } from '../types/explore';
|
||||
|
||||
/**
|
||||
* Type to represent the value of a single query variable.
|
||||
*
|
||||
@@ -129,3 +131,24 @@ export const urlUtil = {
|
||||
appendQueryToUrl,
|
||||
getUrlSearchParams,
|
||||
};
|
||||
|
||||
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
|
||||
if (compact) {
|
||||
return JSON.stringify([
|
||||
urlState.range.from,
|
||||
urlState.range.to,
|
||||
urlState.datasource,
|
||||
...urlState.queries,
|
||||
{ mode: urlState.mode },
|
||||
{
|
||||
ui: [
|
||||
!!urlState.ui.showingGraph,
|
||||
!!urlState.ui.showingLogs,
|
||||
!!urlState.ui.showingTable,
|
||||
urlState.ui.dedupStrategy,
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
return JSON.stringify(urlState);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ChangeEvent, useContext } from 'react';
|
||||
import { DataLink, VariableSuggestion, GrafanaTheme } from '@grafana/data';
|
||||
import { VariableSuggestion, GrafanaTheme, DataLink } from '@grafana/data';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
import { css } from 'emotion';
|
||||
import { ThemeContext, stylesFactory } from '../../themes/index';
|
||||
|
||||
@@ -20,7 +20,23 @@ export const DefaultCell: FC<TableCellProps> = props => {
|
||||
<div className={tableStyles.tableCell}>
|
||||
{link ? (
|
||||
<Tooltip content={link.title}>
|
||||
<a href={link.href} target={link.target} title={link.title} className={tableStyles.tableCellLink}>
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={
|
||||
link.onClick
|
||||
? event => {
|
||||
// Allow opening in new tab
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && link!.onClick) {
|
||||
event.preventDefault();
|
||||
link!.onClick(event);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
target={link.target}
|
||||
title={link.title}
|
||||
className={tableStyles.tableCellLink}
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
@@ -90,6 +90,7 @@ function buildData(theme: GrafanaTheme, config: Record<string, FieldConfig>): Da
|
||||
},
|
||||
theme,
|
||||
replaceVariables: (value: string) => value,
|
||||
getDataSourceSettingsByUid: (value: string) => ({} as any),
|
||||
})[0];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user