|
|
|
import {
|
|
|
|
PanelOptions,
|
|
|
|
ExportTask,
|
|
|
|
DashboardQuery,
|
|
|
|
DatasourceType,
|
|
|
|
ExportStatus,
|
|
|
|
PanelStatus,
|
|
|
|
Dashboard,
|
|
|
|
} from '../types';
|
|
|
|
|
|
|
|
import { convertTimestampToDate, convertTimeZoneTypeToName, getCurrentDashboardUid } from '../../../utils';
|
|
|
|
import {
|
|
|
|
CLOSE_ICON_BASE_64,
|
|
|
|
DOWNLOADING_ICON_BASE_64,
|
|
|
|
OPTIONS_ICON_BASE_64,
|
|
|
|
SELECT_ICON_BASE_64,
|
|
|
|
UNSELECT_ICON_BASE_64,
|
|
|
|
} from '../../../icons';
|
|
|
|
|
|
|
|
import { deleteTask, getStaticFile, getTasks, queryApi } from '../../../services/api_service';
|
|
|
|
import { getDashboardByUid, getDatasources } from '../../../services/grafana_backend_service';
|
|
|
|
|
|
|
|
import { contextSrv } from 'grafana/app/core/core';
|
|
|
|
import { css } from '@emotion/css';
|
|
|
|
|
|
|
|
import {
|
|
|
|
Table,
|
|
|
|
Button,
|
|
|
|
HorizontalGroup,
|
|
|
|
VerticalGroup,
|
|
|
|
Modal,
|
|
|
|
LoadingPlaceholder,
|
|
|
|
TimeRangeInput,
|
|
|
|
useStyles2,
|
|
|
|
} from '@grafana/ui';
|
|
|
|
import {
|
|
|
|
PanelProps,
|
|
|
|
toDataFrame,
|
|
|
|
FieldType,
|
|
|
|
applyFieldOverrides,
|
|
|
|
createTheme,
|
|
|
|
DataFrame,
|
|
|
|
DataLinkClickEvent,
|
|
|
|
PanelModel,
|
|
|
|
DataQuery,
|
|
|
|
DataSourceSettings,
|
|
|
|
TimeRange,
|
|
|
|
OrgRole,
|
|
|
|
} from '@grafana/data';
|
|
|
|
import { RefreshEvent } from '@grafana/runtime';
|
|
|
|
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
import * as _ from 'lodash';
|
|
|
|
|
|
|
|
const PANEL_ID = 'corpglory-dataexporter-panel';
|
|
|
|
const APP_ID = 'corpglory-dataexporter-app';
|
|
|
|
|
|
|
|
interface Props extends PanelProps<PanelOptions> {}
|
|
|
|
|
|
|
|
export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
|
|
|
|
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
|
|
|
const [datasources, setDatasources] = useState<DataSourceSettings[] | null>(null);
|
|
|
|
|
|
|
|
const [tasks, setTasks] = useState<ExportTask[] | null>(null);
|
|
|
|
const [queries, setQueries] = useState<DashboardQuery[] | null>(null);
|
|
|
|
const [taskIdBeingDownloaded, setTaskIdBeingDownloaded] = useState<string | null>(null);
|
|
|
|
|
|
|
|
const [tasksDataFrame, setTasksDataFrame] = useState<DataFrame | null>(null);
|
|
|
|
const [queriesDataFrame, setQueriesDataFrame] = useState<DataFrame | null>(null);
|
|
|
|
|
|
|
|
const [isModalOpen, setModalVisibility] = useState<boolean>(false);
|
|
|
|
|
|
|
|
const [selectedTimeRange, setTimeRange] = useState<TimeRange>(timeRange);
|
|
|
|
|
|
|
|
const [panelStatus, setPanelStatus] = useState<PanelStatus>(PanelStatus.LOADING);
|
|
|
|
function setPanelStatusWithValidate(status: PanelStatus): void {
|
|
|
|
if (panelStatus === PanelStatus.PERMISSION_ERROR) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
return setPanelStatus(status);
|
|
|
|
}
|
|
|
|
|
|
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
|
|
|
|
const timeZoneName = convertTimeZoneTypeToName(timeZone);
|
|
|
|
|
|
|
|
if (contextSrv.user.orgRole !== OrgRole.Admin) {
|
|
|
|
setPanelStatusWithValidate(PanelStatus.PERMISSION_ERROR);
|
|
|
|
}
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
async function getCurrentDashboard(): Promise<any> {
|
|
|
|
const currentDashboardUid = getCurrentDashboardUid();
|
|
|
|
|
|
|
|
return getDashboardByUid(currentDashboardUid);
|
|
|
|
}
|
|
|
|
|
|
|
|
getCurrentDashboard()
|
|
|
|
.then((dash) => setDashboard(dash.dashboard))
|
|
|
|
.catch((err) => console.error(err));
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
getDatasources()
|
|
|
|
.then((datasources) => setDatasources(datasources))
|
|
|
|
.catch((err) => console.error(err));
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (tasks === null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const dataFrame = getDataFrameForTaskTable(tasks);
|
|
|
|
setTasksDataFrame(dataFrame);
|
|
|
|
}, [tasks, taskIdBeingDownloaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
useEffect(fetchTasks, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (queries === null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
setQueriesDataFrame(getDataFrameForQueriesTable(queries));
|
|
|
|
}, [queries]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
async function updateQueries(): Promise<void> {
|
|
|
|
if (!dashboard || !datasources) {
|
|
|
|
console.warn(`Can't update queries if there is no dashboard or datasources`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const queries: DashboardQuery[] = [];
|
|
|
|
dashboard.panels?.forEach((panel: PanelModel) => {
|
|
|
|
// @ts-ignore
|
|
|
|
if (panel.type === PANEL_ID) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!_.includes(_.values(DatasourceType), panel.datasource?.type)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
panel.targets?.forEach((target: DataQuery) => {
|
|
|
|
const datasource = getDatasourceByUid(target.datasource?.uid);
|
|
|
|
if (!datasource) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
queries.push({ selected: false, target, panel, datasource });
|
|
|
|
});
|
|
|
|
});
|
|
|
|
setQueries(queries);
|
|
|
|
}
|
|
|
|
|
|
|
|
function fetchTasks(): void {
|
|
|
|
const dashboardUid = getCurrentDashboardUid();
|
|
|
|
|
|
|
|
getTasks(dashboardUid)
|
|
|
|
.then((tasks) => {
|
|
|
|
setTasks(tasks);
|
|
|
|
setPanelStatusWithValidate(PanelStatus.OK);
|
|
|
|
for (let task of tasks) {
|
|
|
|
if (task.progress?.status === ExportStatus.EXPORTING) {
|
|
|
|
setTimeout(fetchTasks, 1000);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.catch((err) => {
|
|
|
|
setPanelStatusWithValidate(PanelStatus.DATASOURCE_ERROR);
|
|
|
|
console.error('some error', err);
|
|
|
|
setErrorMessage(`${err.name}: ${err.message}`);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
eventBus.subscribe(RefreshEvent, fetchTasks);
|
|
|
|
|
|
|
|
function getDatasourceByUid(uid?: string): DataSourceSettings | undefined {
|
|
|
|
if (_.isNil(uid)) {
|
|
|
|
console.warn(`uid is required to get datasource`);
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if (datasources === null) {
|
|
|
|
console.warn(`there is no datasources yet`);
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
const datasource = _.find(datasources, (datasource: DataSourceSettings) => datasource.uid === uid);
|
|
|
|
|
|
|
|
if (!datasource) {
|
|
|
|
console.warn(`can't find datasource "${uid}"`);
|
|
|
|
}
|
|
|
|
|
|
|
|
return datasource;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function onAddTaskClick(timeZoneName: string): Promise<void> {
|
|
|
|
const selectedQueries = _.filter(queries, (query: DashboardQuery) => query.selected);
|
|
|
|
const timerange: [number, number] = [selectedTimeRange.from.unix(), selectedTimeRange.to.unix()];
|
|
|
|
|
|
|
|
const dashboardUid = getCurrentDashboardUid();
|
|
|
|
|
|
|
|
const task: ExportTask = {
|
|
|
|
dashboardUid,
|
|
|
|
// @ts-ignore
|
|
|
|
username: contextSrv.user.name,
|
|
|
|
timeRange: {
|
|
|
|
from: timerange[0] * 1000,
|
|
|
|
to: timerange[1] * 1000,
|
|
|
|
},
|
|
|
|
queries: selectedQueries,
|
|
|
|
};
|
|
|
|
// TODO: move this function to API Service
|
|
|
|
await queryApi('/task', {
|
|
|
|
method: 'POST',
|
|
|
|
data: {
|
|
|
|
task,
|
|
|
|
url: window.location.toString(),
|
|
|
|
timeZoneName,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
fetchTasks();
|
|
|
|
|
|
|
|
onCloseModal();
|
|
|
|
unselectAllQueries();
|
|
|
|
}
|
|
|
|
|
|
|
|
function unselectAllQueries(): void {
|
|
|
|
if (queries === null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
setQueries(queries.map((query: DashboardQuery) => ({ ...query, selected: false })));
|
|
|
|
}
|
|
|
|
|
|
|
|
function openDatasourceModal(): void {
|
|
|
|
updateQueries();
|
|
|
|
setTimeRange(timeRange);
|
|
|
|
setModalVisibility(true);
|
|
|
|
}
|
|
|
|
|
|
|
|
function onCloseModal(): void {
|
|
|
|
setModalVisibility(false);
|
|
|
|
unselectAllQueries();
|
|
|
|
}
|
|
|
|
|
|
|
|
function getDataFrameForQueriesTable(queries: DashboardQuery[]): DataFrame {
|
|
|
|
const dataFrame = toDataFrame({
|
|
|
|
name: 'A',
|
|
|
|
fields: [
|
|
|
|
{
|
|
|
|
name: 'Select',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(queries, (query) => (query.selected ? SELECT_ICON_BASE_64 : UNSELECT_ICON_BASE_64)),
|
|
|
|
config: {
|
|
|
|
custom: {
|
|
|
|
filterable: false,
|
|
|
|
displayMode: 'image',
|
|
|
|
},
|
|
|
|
links: [
|
|
|
|
{
|
|
|
|
targetBlank: false,
|
|
|
|
title: 'Select',
|
|
|
|
url: '#',
|
|
|
|
onClick: (event: DataLinkClickEvent) => onDatasourceSelectClick(event),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Panel',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(queries, (query) => query.panel.title),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'RefId',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(queries, (query) => query.target.refId),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Datasource',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(queries, (query) => query.datasource.name),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
|
|
|
|
|
|
|
const dataFrames = applyFieldOverrides({
|
|
|
|
data: [dataFrame],
|
|
|
|
fieldConfig: {
|
|
|
|
overrides: [],
|
|
|
|
defaults: {},
|
|
|
|
},
|
|
|
|
theme: createTheme(),
|
|
|
|
replaceVariables: (value: string) => value,
|
|
|
|
});
|
|
|
|
return dataFrames[0];
|
|
|
|
}
|
|
|
|
|
|
|
|
function getDataFrameForTaskTable(tasks: ExportTask[]): DataFrame {
|
|
|
|
const sortedTasks = _.orderBy(tasks, (task) => task.progress?.time, 'desc');
|
|
|
|
const dataFrame = toDataFrame({
|
|
|
|
name: 'A',
|
|
|
|
fields: [
|
|
|
|
{
|
|
|
|
name: 'Status Updated At',
|
|
|
|
type: FieldType.number,
|
|
|
|
values: _.map(sortedTasks, (task) => convertTimestampToDate(task.progress?.time)),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'From',
|
|
|
|
type: FieldType.number,
|
|
|
|
values: _.map(sortedTasks, (task) => convertTimestampToDate(task.timeRange.from)),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'To',
|
|
|
|
type: FieldType.number,
|
|
|
|
values: _.map(sortedTasks, (task) => convertTimestampToDate(task.timeRange.to)),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'User',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(sortedTasks, (task) => task.username),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Datasource',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(sortedTasks, (task) => task.queries.map((query) => query.datasource?.name).join(',')),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Exported Rows',
|
|
|
|
type: FieldType.number,
|
|
|
|
values: _.map(sortedTasks, (task) => task.progress?.exportedRowsCount),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Progress',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(sortedTasks, (task) => `${((task.progress?.progress || 0) * 100).toFixed(0)}%`),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Status',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(sortedTasks, (task) => task.progress?.status),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Error',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(sortedTasks, (task) => task.progress?.errorMessage || '-'),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'Actions',
|
|
|
|
type: FieldType.string,
|
|
|
|
values: _.map(sortedTasks, (task) => {
|
|
|
|
switch (task.progress?.status) {
|
|
|
|
case ExportStatus.FINISHED:
|
|
|
|
if (task.id === taskIdBeingDownloaded) {
|
|
|
|
return DOWNLOADING_ICON_BASE_64;
|
|
|
|
} else {
|
|
|
|
return OPTIONS_ICON_BASE_64;
|
|
|
|
}
|
|
|
|
case ExportStatus.ERROR:
|
|
|
|
return CLOSE_ICON_BASE_64;
|
|
|
|
case ExportStatus.EXPORTING:
|
|
|
|
return '';
|
|
|
|
default:
|
|
|
|
throw new Error(`Unknown exporting status: ${task.progress?.status}`);
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
config: {
|
|
|
|
custom: {
|
|
|
|
filterable: false,
|
|
|
|
displayMode: 'image',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
|
|
|
|
|
|
|
const dataFrames = applyFieldOverrides({
|
|
|
|
data: [dataFrame],
|
|
|
|
fieldConfig: {
|
|
|
|
overrides: [],
|
|
|
|
defaults: {},
|
|
|
|
},
|
|
|
|
theme: createTheme(),
|
|
|
|
replaceVariables: (value: string) => value,
|
|
|
|
});
|
|
|
|
|
|
|
|
// @ts-expect-error
|
|
|
|
dataFrames[0].fields[9].getLinks = (cell: { valueRowIndex: number }) => {
|
|
|
|
const rowIndex = cell.valueRowIndex;
|
|
|
|
const task = _.find(sortedTasks, (task, idx) => idx === rowIndex);
|
|
|
|
if (!task) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
switch (task.progress?.status) {
|
|
|
|
case ExportStatus.FINISHED:
|
|
|
|
return [
|
|
|
|
{
|
|
|
|
targetBlank: false,
|
|
|
|
title: 'Download',
|
|
|
|
url: '#',
|
|
|
|
onClick: () => onDownloadClick(task),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
targetBlank: false,
|
|
|
|
title: 'Delete',
|
|
|
|
url: '#',
|
|
|
|
onClick: () => onDeleteClick(task),
|
|
|
|
},
|
|
|
|
];
|
|
|
|
case ExportStatus.ERROR:
|
|
|
|
return [
|
|
|
|
{
|
|
|
|
targetBlank: false,
|
|
|
|
title: 'Delete',
|
|
|
|
url: '#',
|
|
|
|
onClick: () => onDeleteClick(task),
|
|
|
|
},
|
|
|
|
];
|
|
|
|
case ExportStatus.EXPORTING:
|
|
|
|
return [];
|
|
|
|
default:
|
|
|
|
throw new Error(`Unknown exporting status: ${task.progress?.status}`);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
return dataFrames[0];
|
|
|
|
}
|
|
|
|
|
|
|
|
async function onDeleteClick(taskToDelete: ExportTask): Promise<void> {
|
|
|
|
await deleteTask(taskToDelete?.id);
|
|
|
|
|
|
|
|
const filteredTasks = _.filter(tasks, (task) => task.id !== taskToDelete.id);
|
|
|
|
setTasks(filteredTasks);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function onDownloadClick(task: ExportTask): Promise<void> {
|
|
|
|
setTaskIdBeingDownloaded(task.id as string);
|
|
|
|
await getStaticFile(task?.id);
|
|
|
|
setTaskIdBeingDownloaded(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
function onDatasourceSelectClick(e: DataLinkClickEvent): void {
|
|
|
|
if (queries === null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const rowIndex = e.origin.rowIndex;
|
|
|
|
const updatedQueries = _.clone(queries);
|
|
|
|
updatedQueries[rowIndex].selected = !updatedQueries[rowIndex].selected;
|
|
|
|
setQueries(updatedQueries);
|
|
|
|
}
|
|
|
|
|
|
|
|
const styles = useStyles2(getStyles);
|
|
|
|
|
|
|
|
const loadingDiv = <LoadingPlaceholder text="Loading..."></LoadingPlaceholder>;
|
|
|
|
// TODO: add styles
|
|
|
|
const datasourceErrorDiv = (
|
|
|
|
<div>
|
|
|
|
<p>Datasource is unavailable.</p>
|
|
|
|
<div>
|
|
|
|
If you have not setup the plugin click
|
|
|
|
<a className={styles.customLink} href={`/plugins/${APP_ID}`}>
|
|
|
|
here
|
|
|
|
</a>
|
|
|
|
to configure DataExporter.
|
|
|
|
</div>
|
|
|
|
<p style={{ marginTop: '16px' }}>{errorMessage}</p>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
const permissionErrorDiv = (
|
|
|
|
<div>
|
|
|
|
<p> Permission Error. </p>
|
|
|
|
<div> DataExporter panel available only for Admins. </div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
const mainDiv = (
|
|
|
|
<div>
|
|
|
|
<Table width={width} height={height - 40} data={tasksDataFrame as DataFrame} />
|
|
|
|
<HorizontalGroup justify="flex-end">
|
|
|
|
<Button variant="primary" icon="plus" style={{ marginTop: '8px' }} onClick={openDatasourceModal}>
|
|
|
|
Add Task
|
|
|
|
</Button>
|
|
|
|
<Modal title="Select Queries" isOpen={isModalOpen} onDismiss={onCloseModal} className={styles.calendarModal}>
|
|
|
|
{queriesDataFrame === null ? (
|
|
|
|
<div>
|
|
|
|
<p>There are no queries to export.</p>
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
<div>
|
|
|
|
<VerticalGroup spacing="xs">
|
|
|
|
<HorizontalGroup justify="flex-start" spacing="md">
|
|
|
|
<TimeRangeInput
|
|
|
|
value={selectedTimeRange}
|
|
|
|
onChange={(newTimeRange) => {
|
|
|
|
setTimeRange(newTimeRange);
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</HorizontalGroup>
|
|
|
|
<Table width={width / 2 - 20} height={height - 40} data={queriesDataFrame} />
|
|
|
|
<HorizontalGroup justify="flex-end" spacing="md">
|
|
|
|
<Button
|
|
|
|
variant="primary"
|
|
|
|
aria-label="Add task button"
|
|
|
|
onClick={() => onAddTaskClick(timeZoneName)}
|
|
|
|
// TODO: move to function
|
|
|
|
disabled={!queries?.filter((query: DashboardQuery) => query.selected)?.length}
|
|
|
|
>
|
|
|
|
Add Task
|
|
|
|
</Button>
|
|
|
|
</HorizontalGroup>
|
|
|
|
</VerticalGroup>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</Modal>
|
|
|
|
</HorizontalGroup>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
|
|
|
function renderSwitch(panelStatus: PanelStatus): JSX.Element {
|
|
|
|
switch (panelStatus) {
|
|
|
|
case PanelStatus.LOADING:
|
|
|
|
return loadingDiv;
|
|
|
|
case PanelStatus.DATASOURCE_ERROR:
|
|
|
|
return datasourceErrorDiv;
|
|
|
|
case PanelStatus.PERMISSION_ERROR:
|
|
|
|
return permissionErrorDiv;
|
|
|
|
case PanelStatus.OK:
|
|
|
|
return mainDiv;
|
|
|
|
default:
|
|
|
|
return datasourceErrorDiv;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return <div>{renderSwitch(panelStatus)}</div>;
|
|
|
|
}
|
|
|
|
|
|
|
|
const getStyles = () => ({
|
|
|
|
calendarModal: css`
|
|
|
|
section {
|
|
|
|
position: fixed;
|
|
|
|
top: 20%;
|
|
|
|
z-index: 1061;
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
customLink: css`
|
|
|
|
color: #6e9fff;
|
|
|
|
margin: 0 4px;
|
|
|
|
&:hover {
|
|
|
|
text-decoration: underline;
|
|
|
|
}
|
|
|
|
`,
|
|
|
|
});
|