mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
ShareDrawer: Share snapshot (#89195)
This commit is contained in:
parent
946545cfc5
commit
399651b9ad
@ -66,6 +66,7 @@ export const Pages = {
|
||||
container: 'data-testid new share button menu',
|
||||
shareInternally: 'data-testid new share button share internally',
|
||||
shareExternally: 'data-testid new share button share externally',
|
||||
shareSnapshot: 'data-testid new share button share snapshot',
|
||||
},
|
||||
},
|
||||
playlistControls: {
|
||||
@ -287,6 +288,9 @@ export const Pages = {
|
||||
copyUrlButton: 'data-testid share externally copy url button',
|
||||
shareTypeSelect: 'data-testid share externally share type select',
|
||||
},
|
||||
ShareSnapshot: {
|
||||
container: 'data-testid share snapshot drawer container',
|
||||
},
|
||||
},
|
||||
PublicDashboard: {
|
||||
page: 'public-dashboard-page',
|
||||
|
@ -26,6 +26,7 @@ describe('ShareMenu', () => {
|
||||
|
||||
expect(await screen.findByTestId(selector.shareInternally)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.shareSnapshot)).toBeInTheDocument();
|
||||
});
|
||||
it('should no share externally when public dashboard is disabled', async () => {
|
||||
config.featureToggles.publicDashboards = false;
|
||||
|
@ -4,12 +4,14 @@ import { useAsyncFn } from 'react-use';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Menu } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { ShareDrawer } from '../ShareDrawer/ShareDrawer';
|
||||
|
||||
import { ShareExternally } from './share-externally/ShareExternally';
|
||||
import { ShareSnapshot } from './share-snapshot/ShareSnapshot';
|
||||
import { buildShareUrl } from './utils';
|
||||
|
||||
const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButton.menu;
|
||||
@ -21,30 +23,45 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
|
||||
const onShareExternallyClick = () => {
|
||||
const drawer = new ShareDrawer({
|
||||
title: 'Share externally',
|
||||
title: t('share-dashboard.menu.share-externally-title', 'Share externally'),
|
||||
body: new ShareExternally({}),
|
||||
});
|
||||
|
||||
dashboard.showModal(drawer);
|
||||
};
|
||||
|
||||
const onShareSnapshotClick = () => {
|
||||
const drawer = new ShareDrawer({
|
||||
title: t('share-dashboard.menu.share-snapshot-title', 'Share snapshot'),
|
||||
body: new ShareSnapshot({ dashboardRef: dashboard.getRef() }),
|
||||
});
|
||||
|
||||
dashboard.showModal(drawer);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu data-testid={newShareButtonSelector.container}>
|
||||
<Menu.Item
|
||||
testId={newShareButtonSelector.shareInternally}
|
||||
label="Share internally"
|
||||
description="Copy link"
|
||||
label={t('share-dashboard.menu.share-internally-title', 'Share internally')}
|
||||
description={t('share-dashboard.menu.share-internally-description', 'Advanced settings')}
|
||||
icon="building"
|
||||
onClick={buildUrl}
|
||||
/>
|
||||
{isPublicDashboardsEnabled() && (
|
||||
<Menu.Item
|
||||
testId={newShareButtonSelector.shareExternally}
|
||||
label="Share externally"
|
||||
label={t('share-dashboard.menu.share-externally-title', 'Share externally')}
|
||||
icon="share-alt"
|
||||
onClick={onShareExternallyClick}
|
||||
/>
|
||||
)}
|
||||
<Menu.Item
|
||||
testId={newShareButtonSelector.shareSnapshot}
|
||||
label={t('share-dashboard.menu.share-snapshot-title', 'Share snapshot')}
|
||||
icon="camera"
|
||||
onClick={onShareSnapshotClick}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,102 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Field,
|
||||
RadioButtonGroup,
|
||||
Spinner,
|
||||
Stack,
|
||||
Text,
|
||||
TextLink,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Input } from '@grafana/ui/src/components/Input/Input';
|
||||
import { t } from '@grafana/ui/src/utils/i18n';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { SnapshotSharingOptions } from '../../../../dashboard/services/SnapshotSrv';
|
||||
import { getExpireOptions } from '../../ShareSnapshotTab';
|
||||
|
||||
const SNAPSHOT_URL = 'https://grafana.com/docs/grafana/latest/dashboards/share-dashboards-panels/#publish-a-snapshot';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
name: string;
|
||||
selectedExpireOption: SelectableValue<number>;
|
||||
sharingOptions?: SnapshotSharingOptions;
|
||||
onCancelClick: () => void;
|
||||
onCreateClick: (isExternal?: boolean) => void;
|
||||
onNameChange: (v: string) => void;
|
||||
onExpireChange: (v: number) => void;
|
||||
}
|
||||
export function CreateSnapshot({
|
||||
name,
|
||||
onNameChange,
|
||||
onExpireChange,
|
||||
selectedExpireOption,
|
||||
sharingOptions,
|
||||
onCancelClick,
|
||||
onCreateClick,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Alert severity="info" title={''}>
|
||||
<Stack justifyContent="space-between" gap={2} alignItems="center">
|
||||
<Text>
|
||||
<Trans i18nKey="snapshot.share.info-alert">
|
||||
A Grafana dashboard snapshot publicly shares a dashboard while removing sensitive data such as queries and
|
||||
panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button variant="secondary" onClick={() => window.open(SNAPSHOT_URL, '_blank')} type="button">
|
||||
<Trans i18nKey="snapshot.share.learn-more-button">Learn more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
<Field label={t('snapshot.share.name-label', 'Snapshot name*')}>
|
||||
<Input id="snapshot-name-input" defaultValue={name} onBlur={(e) => onNameChange(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('snapshot.share.expiration-label', 'Expires in')}>
|
||||
<RadioButtonGroup<number>
|
||||
id="expire-select-input"
|
||||
options={getExpireOptions()}
|
||||
value={selectedExpireOption?.value}
|
||||
onChange={onExpireChange}
|
||||
/>
|
||||
</Field>
|
||||
<Divider />
|
||||
<Stack justifyContent="space-between" direction={{ xs: 'column', xl: 'row' }}>
|
||||
<Stack gap={1} flex={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<Button variant="primary" disabled={isLoading} onClick={() => onCreateClick()}>
|
||||
<Trans i18nKey="snapshot.share.local-button">Publish snapshot</Trans>
|
||||
</Button>
|
||||
{sharingOptions?.externalEnabled && (
|
||||
<Button variant="secondary" disabled={isLoading} onClick={() => onCreateClick(true)}>
|
||||
{sharingOptions?.externalSnapshotName}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" fill="outline" onClick={onCancelClick}>
|
||||
<Trans i18nKey="snapshot.share.cancel-button">Cancel</Trans>
|
||||
</Button>
|
||||
{isLoading && <Spinner />}
|
||||
</Stack>
|
||||
<TextLink icon="external-link-alt" href="/dashboard/snapshots">
|
||||
{t('snapshot.share.view-all-button', 'View all snapshots')}
|
||||
</TextLink>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
paddingBottom: theme.spacing(2),
|
||||
}),
|
||||
});
|
@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { ShareDrawerConfirmAction } from '../../ShareDrawer/ShareDrawerConfirmAction';
|
||||
import { ShareSnapshotTab } from '../../ShareSnapshotTab';
|
||||
|
||||
import { CreateSnapshot } from './CreateSnapshot';
|
||||
import { SnapshotActions } from './SnapshotActions';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardDrawer.ShareSnapshot;
|
||||
|
||||
export class ShareSnapshot extends ShareSnapshotTab {
|
||||
static Component = ShareSnapshotRenderer;
|
||||
}
|
||||
|
||||
function ShareSnapshotRenderer({ model }: SceneComponentProps<ShareSnapshot>) {
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [showDeletedAlert, setShowDeletedAlert] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
const { snapshotName, snapshotSharingOptions, selectedExpireOption, dashboardRef } = model.useState();
|
||||
|
||||
const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => {
|
||||
const response = await model.onSnapshotCreate(external);
|
||||
setStep(2);
|
||||
return response;
|
||||
});
|
||||
const [deleteSnapshotResult, deleteSnapshot] = useAsyncFn(async (url: string) => {
|
||||
const response = await model.onSnapshotDelete(url);
|
||||
setStep(1);
|
||||
setShowDeleteConfirmation(false);
|
||||
setShowDeletedAlert(true);
|
||||
return response;
|
||||
});
|
||||
|
||||
const onCancelClick = () => {
|
||||
dashboardRef.resolve().closeModal();
|
||||
};
|
||||
|
||||
if (showDeleteConfirmation) {
|
||||
return (
|
||||
<ShareDrawerConfirmAction
|
||||
title={t('snapshot.share.delete-title', 'Delete snapshot')}
|
||||
confirmButtonLabel={t('snapshot.share.delete-button', 'Delete snapshot')}
|
||||
onConfirm={() => deleteSnapshot(snapshotResult.value?.deleteUrl!)}
|
||||
onDismiss={() => setShowDeleteConfirmation(false)}
|
||||
description={t('snapshot.share.delete-description', 'Are you sure you want to delete this snapshot?')}
|
||||
isActionLoading={deleteSnapshotResult.loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={selectors.container}>
|
||||
{step === 1 && (
|
||||
<>
|
||||
{showDeletedAlert && (
|
||||
<Alert severity="info" title={''} onRemove={() => setShowDeletedAlert(false)}>
|
||||
<Trans i18nKey="snapshot.share.deleted-alert">
|
||||
Your snapshot has been deleted. It might take up to an hour before the snapshot is cleared from any CDN
|
||||
caches.
|
||||
</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
<CreateSnapshot
|
||||
name={snapshotName ?? ''}
|
||||
selectedExpireOption={selectedExpireOption}
|
||||
sharingOptions={snapshotSharingOptions}
|
||||
onNameChange={model.onSnasphotNameChange}
|
||||
onCancelClick={onCancelClick}
|
||||
onExpireChange={model.onExpireChange}
|
||||
onCreateClick={createSnapshot}
|
||||
isLoading={snapshotResult.loading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<SnapshotActions
|
||||
url={snapshotResult.value!.url}
|
||||
onDeleteClick={() => setShowDeleteConfirmation(true)}
|
||||
onNewSnapshotClick={() => setStep(1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, ClipboardButton, Stack } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
onDeleteClick: () => void;
|
||||
onNewSnapshotClick: () => void;
|
||||
}
|
||||
export const SnapshotActions = ({ url, onDeleteClick, onNewSnapshotClick }: Props) => {
|
||||
return (
|
||||
<Stack justifyContent="flex-start" gap={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<ClipboardButton icon="link" variant="primary" fill="outline" getText={() => url}>
|
||||
<Trans i18nKey="snapshot.share.copy-link-button">Copy link</Trans>
|
||||
</ClipboardButton>
|
||||
<Button icon="trash-alt" variant="destructive" fill="outline" onClick={onDeleteClick}>
|
||||
<Trans i18nKey="snapshot.share.delete-button">Delete snapshot</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" fill="solid" onClick={onNewSnapshotClick}>
|
||||
<Trans i18nKey="snapshot.share.new-snapshot-button">New snapshot</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
@ -6,9 +6,12 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectRef, VizPanel } from '@grafana/scenes';
|
||||
import { Button, ClipboardButton, Field, Input, Modal, RadioButtonGroup, Stack } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
|
||||
import { getDashboardSnapshotSrv, SnapshotSharingOptions } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { transformSceneToSaveModel, trimDashboardForSnapshot } from '../serialization/transformSceneToSaveModel';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
@ -17,7 +20,7 @@ import { SceneShareTabState } from './types';
|
||||
|
||||
const selectors = e2eSelectors.pages.ShareDashboardModal.SnapshotScene;
|
||||
|
||||
const getExpireOptions = () => {
|
||||
export const getExpireOptions = () => {
|
||||
const DEFAULT_EXPIRE_OPTION: SelectableValue<number> = {
|
||||
label: t('share-modal.snapshot.expire-week', '1 Week'),
|
||||
value: 60 * 60 * 24 * 7,
|
||||
@ -46,17 +49,17 @@ const getDefaultExpireOption = () => {
|
||||
|
||||
export interface ShareSnapshotTabState extends SceneShareTabState {
|
||||
panelRef?: SceneObjectRef<VizPanel>;
|
||||
snapshotName?: string;
|
||||
selectedExpireOption?: SelectableValue<number>;
|
||||
snapshotName: string;
|
||||
selectedExpireOption: SelectableValue<number>;
|
||||
|
||||
snapshotSharingOptions?: SnapshotSharingOptions;
|
||||
}
|
||||
|
||||
export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
|
||||
public tabId = shareDashboardType.snapshot;
|
||||
static Component = ShareSnapshoTabRenderer;
|
||||
static Component = ShareSnapshotTabRenderer;
|
||||
|
||||
public constructor(state: ShareSnapshotTabState) {
|
||||
public constructor(state: Omit<ShareSnapshotTabState, 'snapshotName' | 'selectedExpireOption'>) {
|
||||
super({
|
||||
...state,
|
||||
snapshotName: state.dashboardRef.resolve().state.title,
|
||||
@ -124,7 +127,11 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
|
||||
};
|
||||
|
||||
try {
|
||||
return await getDashboardSnapshotSrv().create(cmdData);
|
||||
const response = await getDashboardSnapshotSrv().create(cmdData);
|
||||
dispatch(
|
||||
notifyApp(createSuccessNotification(t('snapshot.share.success-creation', 'Your snapshot has been created')))
|
||||
);
|
||||
return response;
|
||||
} finally {
|
||||
if (external) {
|
||||
DashboardInteractions.publishSnapshotClicked({
|
||||
@ -139,9 +146,17 @@ export class ShareSnapshotTab extends SceneObjectBase<ShareSnapshotTabState> {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public onSnapshotDelete = async (url: string) => {
|
||||
const response = await getBackendSrv().get(url);
|
||||
dispatch(
|
||||
notifyApp(createSuccessNotification(t('snapshot.share.success-delete', 'Your snapshot has been deleted')))
|
||||
);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
function ShareSnapshoTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab>) {
|
||||
function ShareSnapshotTabRenderer({ model }: SceneComponentProps<ShareSnapshotTab>) {
|
||||
const { snapshotName, selectedExpireOption, modalRef, snapshotSharingOptions } = model.useState();
|
||||
|
||||
const [snapshotResult, createSnapshot] = useAsyncFn(async (external = false) => {
|
||||
|
@ -1695,6 +1695,14 @@
|
||||
"title": "You haven't created any service accounts yet"
|
||||
}
|
||||
},
|
||||
"share-dashboard": {
|
||||
"menu": {
|
||||
"share-externally-title": "Share externally",
|
||||
"share-internally-description": "Advanced settings",
|
||||
"share-internally-title": "Share internally",
|
||||
"share-snapshot-title": "Share snapshot"
|
||||
}
|
||||
},
|
||||
"share-drawer": {
|
||||
"confirm-action": {
|
||||
"back-arrow-button": "Back button"
|
||||
@ -1845,6 +1853,23 @@
|
||||
},
|
||||
"external-badge": "External",
|
||||
"name-column-header": "Name",
|
||||
"share": {
|
||||
"cancel-button": "Cancel",
|
||||
"copy-link-button": "Copy link",
|
||||
"delete-button": "Delete snapshot",
|
||||
"delete-description": "Are you sure you want to delete this snapshot?",
|
||||
"delete-title": "Delete snapshot",
|
||||
"deleted-alert": "Your snapshot has been deleted. It might take up to an hour before the snapshot is cleared from any CDN caches.",
|
||||
"expiration-label": "Expires in",
|
||||
"info-alert": "A Grafana dashboard snapshot publicly shares a dashboard while removing sensitive data such as queries and panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot.",
|
||||
"learn-more-button": "Learn more",
|
||||
"local-button": "Publish snapshot",
|
||||
"name-label": "Snapshot name*",
|
||||
"new-snapshot-button": "New snapshot",
|
||||
"success-creation": "Your snapshot has been created",
|
||||
"success-delete": "Your snapshot has been deleted",
|
||||
"view-all-button": "View all snapshots"
|
||||
},
|
||||
"url-column-header": "Snapshot url",
|
||||
"view-button": "View"
|
||||
},
|
||||
|
@ -1695,6 +1695,14 @@
|
||||
"title": "Ÿőū ĥävęʼn'ŧ čřęäŧęđ äʼny şęřvįčę äččőūʼnŧş yęŧ"
|
||||
}
|
||||
},
|
||||
"share-dashboard": {
|
||||
"menu": {
|
||||
"share-externally-title": "Ŝĥäřę ęχŧęřʼnäľľy",
|
||||
"share-internally-description": "Åđväʼnčęđ şęŧŧįʼnģş",
|
||||
"share-internally-title": "Ŝĥäřę įʼnŧęřʼnäľľy",
|
||||
"share-snapshot-title": "Ŝĥäřę şʼnäpşĥőŧ"
|
||||
}
|
||||
},
|
||||
"share-drawer": {
|
||||
"confirm-action": {
|
||||
"back-arrow-button": "ßäčĸ þūŧŧőʼn"
|
||||
@ -1845,6 +1853,23 @@
|
||||
},
|
||||
"external-badge": "Ēχŧęřʼnäľ",
|
||||
"name-column-header": "Ńämę",
|
||||
"share": {
|
||||
"cancel-button": "Cäʼnčęľ",
|
||||
"copy-link-button": "Cőpy ľįʼnĸ",
|
||||
"delete-button": "Đęľęŧę şʼnäpşĥőŧ",
|
||||
"delete-description": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő đęľęŧę ŧĥįş şʼnäpşĥőŧ?",
|
||||
"delete-title": "Đęľęŧę şʼnäpşĥőŧ",
|
||||
"deleted-alert": "Ÿőūř şʼnäpşĥőŧ ĥäş þęęʼn đęľęŧęđ. Ĩŧ mįģĥŧ ŧäĸę ūp ŧő äʼn ĥőūř þęƒőřę ŧĥę şʼnäpşĥőŧ įş čľęäřęđ ƒřőm äʼny CĐŃ čäčĥęş.",
|
||||
"expiration-label": "Ēχpįřęş įʼn",
|
||||
"info-alert": "Å Ğřäƒäʼnä đäşĥþőäřđ şʼnäpşĥőŧ pūþľįčľy şĥäřęş ä đäşĥþőäřđ ŵĥįľę řęmővįʼnģ şęʼnşįŧįvę đäŧä şūčĥ äş qūęřįęş äʼnđ päʼnęľ ľįʼnĸş, ľęävįʼnģ őʼnľy vįşįþľę męŧřįčş äʼnđ şęřįęş ʼnämęş. Åʼnyőʼnę ŵįŧĥ ŧĥę ľįʼnĸ čäʼn äččęşş ŧĥę şʼnäpşĥőŧ.",
|
||||
"learn-more-button": "Ŀęäřʼn mőřę",
|
||||
"local-button": "Pūþľįşĥ şʼnäpşĥőŧ",
|
||||
"name-label": "Ŝʼnäpşĥőŧ ʼnämę*",
|
||||
"new-snapshot-button": "Ńęŵ şʼnäpşĥőŧ",
|
||||
"success-creation": "Ÿőūř şʼnäpşĥőŧ ĥäş þęęʼn čřęäŧęđ",
|
||||
"success-delete": "Ÿőūř şʼnäpşĥőŧ ĥäş þęęʼn đęľęŧęđ",
|
||||
"view-all-button": "Vįęŵ äľľ şʼnäpşĥőŧş"
|
||||
},
|
||||
"url-column-header": "Ŝʼnäpşĥőŧ ūřľ",
|
||||
"view-button": "Vįęŵ"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user