add options panel
This commit is contained in:
@@ -401,6 +401,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
|
||||
direction: 'row',
|
||||
initialSize: 0.25,
|
||||
handleSize: 'xs',
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -423,7 +424,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
|
||||
onRemoveTransform={handleRemoveTransform}
|
||||
/>
|
||||
</div>
|
||||
<div {...splitterProps} className={cx(splitterProps.className, styles.splitter)} />
|
||||
<div
|
||||
{...splitterProps}
|
||||
className={cx(splitterProps.className, styles.splitter)}
|
||||
style={{ ...splitterProps.style, width: 0 }}
|
||||
/>
|
||||
<div {...secondaryProps}>
|
||||
<DetailView selectedItem={selectedItem} panel={panel} tabs={tabs} />
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { CoreApp, DataQuery, DataSourcePluginContextProvider, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import {
|
||||
CoreApp,
|
||||
DataQuery,
|
||||
DataSourcePluginContextProvider,
|
||||
GrafanaTheme2,
|
||||
TimeRange,
|
||||
getDataSourceRef,
|
||||
} from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { SceneDataQuery, VizPanel } from '@grafana/scenes';
|
||||
import { SceneDataQuery, VizPanel, sceneGraph, SceneQueryRunner } from '@grafana/scenes';
|
||||
import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui';
|
||||
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
||||
import { QueryErrorAlert } from 'app/features/query/components/QueryErrorAlert';
|
||||
import { QueryGroupOptionsEditor } from 'app/features/query/components/QueryGroupOptions';
|
||||
import { QueryGroupOptions } from 'app/types/query';
|
||||
|
||||
import { PanelTimeRange } from '../../scene/panel-timerange/PanelTimeRange';
|
||||
import { getQueryRunnerFor } from '../../utils/utils';
|
||||
import { getUpdatedHoverHeader } from '../getPanelFrameOptions';
|
||||
|
||||
interface QueryDetailViewProps {
|
||||
panel: VizPanel;
|
||||
@@ -20,6 +31,7 @@ interface QueryDetailViewProps {
|
||||
|
||||
export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
const dsSettings = useMemo(() => {
|
||||
try {
|
||||
@@ -75,6 +87,89 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
|
||||
}
|
||||
}, [queryRunner]);
|
||||
|
||||
// FIXME this should be a memo or some other structure, not a callback
|
||||
const buildQueryOptions = useCallback((): QueryGroupOptions => {
|
||||
if (!queryRunner) {
|
||||
return {
|
||||
queries: [],
|
||||
dataSource: dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined },
|
||||
};
|
||||
}
|
||||
|
||||
const timeRangeObj = sceneGraph.getTimeRange(panel);
|
||||
|
||||
let timeRangeOpts: QueryGroupOptions['timeRange'] = {
|
||||
from: undefined,
|
||||
shift: undefined,
|
||||
hide: undefined,
|
||||
};
|
||||
|
||||
if (timeRangeObj instanceof PanelTimeRange) {
|
||||
timeRangeOpts = {
|
||||
from: timeRangeObj.state.timeFrom,
|
||||
shift: timeRangeObj.state.timeShift,
|
||||
hide: timeRangeObj.state.hideTimeOverride,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? queryRunner.state.cacheTimeout : undefined,
|
||||
queryCachingTTL: dsSettings?.cachingConfig?.enabled ? queryRunner.state.queryCachingTTL : undefined,
|
||||
dataSource: {
|
||||
default: dsSettings?.isDefault,
|
||||
...(dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }),
|
||||
},
|
||||
queries: queryRunner.state.queries,
|
||||
maxDataPoints: queryRunner.state.maxDataPoints,
|
||||
minInterval: queryRunner.state.minInterval,
|
||||
timeRange: timeRangeOpts,
|
||||
};
|
||||
}, [queryRunner, panel, dsSettings]);
|
||||
|
||||
const handleQueryOptionsChange = useCallback(
|
||||
(options: QueryGroupOptions) => {
|
||||
if (!queryRunner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataObjStateUpdate: Partial<SceneQueryRunner['state']> = {};
|
||||
const panelStateUpdate: Partial<VizPanel['state']> = {};
|
||||
|
||||
if (options.maxDataPoints !== queryRunner.state.maxDataPoints) {
|
||||
dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined;
|
||||
}
|
||||
|
||||
if (options.minInterval !== queryRunner.state.minInterval) {
|
||||
dataObjStateUpdate.minInterval = options.minInterval ?? undefined;
|
||||
}
|
||||
|
||||
const timeFrom = options.timeRange?.from ?? undefined;
|
||||
const timeShift = options.timeRange?.shift ?? undefined;
|
||||
const hideTimeOverride = options.timeRange?.hide;
|
||||
|
||||
if (timeFrom !== undefined || timeShift !== undefined) {
|
||||
panelStateUpdate.$timeRange = new PanelTimeRange({ timeFrom, timeShift, hideTimeOverride });
|
||||
panelStateUpdate.hoverHeader = getUpdatedHoverHeader(panel.state.title, panelStateUpdate.$timeRange);
|
||||
} else {
|
||||
panelStateUpdate.$timeRange = undefined;
|
||||
panelStateUpdate.hoverHeader = getUpdatedHoverHeader(panel.state.title, undefined);
|
||||
}
|
||||
|
||||
if (options.cacheTimeout !== queryRunner.state.cacheTimeout) {
|
||||
dataObjStateUpdate.cacheTimeout = options.cacheTimeout;
|
||||
}
|
||||
|
||||
if (options.queryCachingTTL !== queryRunner.state.queryCachingTTL) {
|
||||
dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL;
|
||||
}
|
||||
|
||||
panel.setState(panelStateUpdate);
|
||||
queryRunner.setState(dataObjStateUpdate);
|
||||
queryRunner.runQueries();
|
||||
},
|
||||
[queryRunner, panel]
|
||||
);
|
||||
|
||||
const renderQueryEditor = () => {
|
||||
if (!datasource || !dsSettings) {
|
||||
return (
|
||||
@@ -114,21 +209,72 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
|
||||
|
||||
const error = data?.error || data?.errors?.find((e) => e.refId === query.refId);
|
||||
|
||||
const queryOptions = buildQueryOptions();
|
||||
const panelData = queryRunnerState?.data;
|
||||
|
||||
const renderCollapsedText = (): React.ReactNode | undefined => {
|
||||
if (!panelData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let mdDesc = queryOptions.maxDataPoints ?? '';
|
||||
if (mdDesc === '' && panelData.request) {
|
||||
mdDesc = `auto = ${panelData.request.maxDataPoints}`;
|
||||
}
|
||||
|
||||
const intervalDesc = panelData.request?.interval ?? queryOptions.minInterval;
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<span className={styles.collapsedText}>
|
||||
<Trans i18nKey="query.query-group-options-editor.collapsed-max-data-points">MD = {{ mdDesc }}</Trans>
|
||||
</span>
|
||||
}
|
||||
{
|
||||
<span className={styles.collapsedText}>
|
||||
<Trans i18nKey="query.query-group-options-editor.collapsed-interval">Interval = {{ intervalDesc }}</Trans>
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<QueryOperationRow
|
||||
id={`query-${query.refId}`}
|
||||
index={queryIndex}
|
||||
draggable={false}
|
||||
collapsable={false}
|
||||
isOpen={true}
|
||||
hideHeader={true}
|
||||
>
|
||||
<div className={styles.queryContent}>
|
||||
{renderQueryEditor()}
|
||||
{error && <QueryErrorAlert error={error} />}
|
||||
<div className={cx(styles.contentWrapper, showOptions && styles.contentWrapperTwoColumn)}>
|
||||
<div className={styles.mainContent}>
|
||||
<QueryOperationRow
|
||||
id={`query-${query.refId}`}
|
||||
index={queryIndex}
|
||||
draggable={false}
|
||||
collapsable={false}
|
||||
isOpen={true}
|
||||
hideHeader={true}
|
||||
>
|
||||
<div className={styles.queryContent}>
|
||||
{renderQueryEditor()}
|
||||
{error && <QueryErrorAlert error={error} />}
|
||||
</div>
|
||||
</QueryOperationRow>
|
||||
</div>
|
||||
</QueryOperationRow>
|
||||
{showOptions && datasource && panelData && (
|
||||
<div className={styles.optionsColumn}>
|
||||
<QueryGroupOptionsEditor
|
||||
options={queryOptions}
|
||||
dataSource={datasource}
|
||||
data={panelData}
|
||||
onChange={handleQueryOptionsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{renderCollapsedText()}
|
||||
<button type="button" className={styles.optionsLink} onClick={() => setShowOptions(!showOptions)}>
|
||||
<Trans i18nKey="dashboard-scene.query-detail-view.options">Options</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,21 +282,71 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
contentWrapper: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
width: '100%',
|
||||
minHeight: 'calc(100% - 36px)', // 36px for footer
|
||||
}),
|
||||
contentWrapperTwoColumn: css({
|
||||
gridTemplateColumns: '1fr 0.5fr',
|
||||
}),
|
||||
mainContent: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(2),
|
||||
width: '100%',
|
||||
gap: theme.spacing(1),
|
||||
overflow: 'scroll',
|
||||
padding: theme.spacing(2, 2, 0, 2),
|
||||
}),
|
||||
queryContent: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
minHeight: '100%',
|
||||
}),
|
||||
footer: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
padding: theme.spacing(1, 2),
|
||||
background: theme.colors.background.secondary,
|
||||
}),
|
||||
optionsLink: css({
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: theme.colors.text.link,
|
||||
cursor: 'pointer',
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
padding: 0,
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}),
|
||||
optionsColumn: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingLeft: theme.spacing(2),
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
background: theme.colors.background.secondary,
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
noEditor: css({
|
||||
padding: theme.spacing(2),
|
||||
textAlign: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
collapsedText: css({
|
||||
marginInline: theme.spacing(1),
|
||||
fontSize: theme.typography.size.sm,
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,9 +2,8 @@ import { css, cx } from '@emotion/css';
|
||||
import React, { useState, ChangeEvent, FocusEvent, useCallback } from 'react';
|
||||
|
||||
import { rangeUtil, PanelData, DataSourceApi, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Input, InlineSwitch, useStyles2, InlineLabel } from '@grafana/ui';
|
||||
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
|
||||
import { QueryGroupOptions } from 'app/types/query';
|
||||
|
||||
interface Props {
|
||||
@@ -18,7 +17,6 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
|
||||
const [timeRangeFrom, setTimeRangeFrom] = useState(options.timeRange?.from || '');
|
||||
const [timeRangeShift, setTimeRangeShift] = useState(options.timeRange?.shift || '');
|
||||
const [timeRangeHide, setTimeRangeHide] = useState(options.timeRange?.hide ?? false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [relativeTimeIsValid, setRelativeTimeIsValid] = useState(true);
|
||||
const [timeShiftIsValid, setTimeShiftIsValid] = useState(true);
|
||||
|
||||
@@ -141,14 +139,6 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const onOpenOptions = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCloseOptions = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const renderCacheTimeoutOption = () => {
|
||||
const tooltip = `If your time series store has a query cache this option can override the default cache timeout. Specify a
|
||||
numeric value in seconds.`;
|
||||
@@ -294,117 +284,79 @@ export const QueryGroupOptionsEditor = React.memo(({ options, dataSource, data,
|
||||
);
|
||||
};
|
||||
|
||||
const renderCollapsedText = (): React.ReactNode | undefined => {
|
||||
if (isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let mdDesc = options.maxDataPoints ?? '';
|
||||
if (mdDesc === '' && data.request) {
|
||||
mdDesc = `auto = ${data.request.maxDataPoints}`;
|
||||
}
|
||||
|
||||
const intervalDesc = data.request?.interval ?? options.minInterval;
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<span className={styles.collapsedText}>
|
||||
<Trans i18nKey="query.query-group-options-editor.collapsed-max-data-points">MD = {{ mdDesc }}</Trans>
|
||||
</span>
|
||||
}
|
||||
{
|
||||
<span className={styles.collapsedText}>
|
||||
<Trans i18nKey="query.query-group-options-editor.collapsed-interval">Interval = {{ intervalDesc }}</Trans>
|
||||
</span>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryOperationRow
|
||||
id="Query options"
|
||||
index={0}
|
||||
title={t('query.query-group-options-editor.Query options-title-query-options', 'Query options')}
|
||||
headerElement={renderCollapsedText()}
|
||||
isOpen={isOpen}
|
||||
onOpen={onOpenOptions}
|
||||
onClose={onCloseOptions}
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{renderMaxDataPointsOption()}
|
||||
{renderIntervalOption()}
|
||||
{renderCacheTimeoutOption()}
|
||||
{renderQueryCachingTTLOption()}
|
||||
<div className={styles.grid}>
|
||||
{renderMaxDataPointsOption()}
|
||||
{renderIntervalOption()}
|
||||
{renderCacheTimeoutOption()}
|
||||
{renderQueryCachingTTLOption()}
|
||||
|
||||
<InlineLabel
|
||||
htmlFor="relative-time-input"
|
||||
tooltip={
|
||||
<Trans
|
||||
i18nKey="query.query-group-options-editor.relative-time-tooltip"
|
||||
values={{ relativeFrom: 'now-5m', relativeTo: '5m', variable: '$_relativeTime' }}
|
||||
>
|
||||
Overrides the relative time range for individual panels, which causes them to be different than what is
|
||||
selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure
|
||||
the Last 5 minutes the Relative time should be <code>{'{{relativeFrom}}'}</code> and{' '}
|
||||
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="query.query-group-options-editor.relative-time">Relative time</Trans>
|
||||
</InlineLabel>
|
||||
<Input
|
||||
id="relative-time-input"
|
||||
type="text"
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1h"
|
||||
onChange={onRelativeTimeChange}
|
||||
onBlur={onOverrideTime}
|
||||
invalid={!relativeTimeIsValid}
|
||||
value={timeRangeFrom}
|
||||
/>
|
||||
<InlineLabel
|
||||
htmlFor="time-shift-input"
|
||||
className={styles.firstColumn}
|
||||
tooltip={
|
||||
<Trans
|
||||
i18nKey="query.query-group-options-editor.time-shift-tooltip"
|
||||
values={{ relativeFrom: 'now-1h', relativeTo: '1h', variable: '$_timeShift' }}
|
||||
>
|
||||
Overrides the time range for individual panels by shifting its start and end relative to the time picker.
|
||||
For example to configure the Last 1h the Time shift should be <code>{'{{relativeFrom}}'}</code> and{' '}
|
||||
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="query.query-group-options-editor.time-shift">Time shift</Trans>
|
||||
</InlineLabel>
|
||||
<Input
|
||||
id="time-shift-input"
|
||||
type="text"
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1h"
|
||||
onChange={onTimeShiftChange}
|
||||
onBlur={onTimeShift}
|
||||
invalid={!timeShiftIsValid}
|
||||
value={timeRangeShift}
|
||||
/>
|
||||
{(timeRangeShift || timeRangeFrom) && (
|
||||
<>
|
||||
<InlineLabel htmlFor="hide-time-info-switch" className={styles.firstColumn}>
|
||||
<Trans i18nKey="query.query-group-options-editor.hide-time-info">Hide time info</Trans>
|
||||
</InlineLabel>
|
||||
<InlineSwitch
|
||||
id="hide-time-info-switch"
|
||||
className={styles.left}
|
||||
value={timeRangeHide}
|
||||
onChange={onToggleTimeOverride}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</QueryOperationRow>
|
||||
<InlineLabel
|
||||
htmlFor="relative-time-input"
|
||||
tooltip={
|
||||
<Trans
|
||||
i18nKey="query.query-group-options-editor.relative-time-tooltip"
|
||||
values={{ relativeFrom: 'now-5m', relativeTo: '5m', variable: '$_relativeTime' }}
|
||||
>
|
||||
Overrides the relative time range for individual panels, which causes them to be different than what is
|
||||
selected in the dashboard time picker in the top-right corner of the dashboard. For example to configure the
|
||||
Last 5 minutes the Relative time should be <code>{'{{relativeFrom}}'}</code> and{' '}
|
||||
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="query.query-group-options-editor.relative-time">Relative time</Trans>
|
||||
</InlineLabel>
|
||||
<Input
|
||||
id="relative-time-input"
|
||||
type="text"
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1h"
|
||||
onChange={onRelativeTimeChange}
|
||||
onBlur={onOverrideTime}
|
||||
invalid={!relativeTimeIsValid}
|
||||
value={timeRangeFrom}
|
||||
/>
|
||||
<InlineLabel
|
||||
htmlFor="time-shift-input"
|
||||
className={styles.firstColumn}
|
||||
tooltip={
|
||||
<Trans
|
||||
i18nKey="query.query-group-options-editor.time-shift-tooltip"
|
||||
values={{ relativeFrom: 'now-1h', relativeTo: '1h', variable: '$_timeShift' }}
|
||||
>
|
||||
Overrides the time range for individual panels by shifting its start and end relative to the time picker.
|
||||
For example to configure the Last 1h the Time shift should be <code>{'{{relativeFrom}}'}</code> and{' '}
|
||||
<code>{'{{relativeTo}}'}</code>, or variables like <code>{'{{variable}}'}</code>.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="query.query-group-options-editor.time-shift">Time shift</Trans>
|
||||
</InlineLabel>
|
||||
<Input
|
||||
id="time-shift-input"
|
||||
type="text"
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1h"
|
||||
onChange={onTimeShiftChange}
|
||||
onBlur={onTimeShift}
|
||||
invalid={!timeShiftIsValid}
|
||||
value={timeRangeShift}
|
||||
/>
|
||||
{(timeRangeShift || timeRangeFrom) && (
|
||||
<>
|
||||
<InlineLabel htmlFor="hide-time-info-switch" className={styles.firstColumn}>
|
||||
<Trans i18nKey="query.query-group-options-editor.hide-time-info">Hide time info</Trans>
|
||||
</InlineLabel>
|
||||
<InlineSwitch
|
||||
id="hide-time-info-switch"
|
||||
className={styles.left}
|
||||
value={timeRangeHide}
|
||||
onChange={onToggleTimeOverride}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user