You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

585 lines
17 KiB

import {
PanelOptions,
ExportTask,
DashboardQuery,
DatasourceType,
ExportStatus,
PanelStatus,
Dashboard,
} from '../types';
import { convertTimestampToDate, convertTimeZoneTypeToName, getCurrentDashboardUid } from '../../../utils';
import {
CLOSE_ICON_BASE_64,
PRELOADER_ICON_BASE_64,
SELECT_ICON_BASE_64,
UNSELECT_ICON_BASE_64,
DOWNLOAD_ICON_BASE_64,
DISABLED_DOWNLOAD_ICON_BASE_64,
} from '../../../icons';
import { deleteTask, getStaticFile, getTasks, queryApi } from '../../../services/api_service';
import { getDashboardByUid, getDatasources } from '../../../services/grafana_backend_service';
import { appEvents, contextSrv } from 'grafana/app/core/core';
import { css } from '@emotion/css';
import {
Table,
Button,
Select,
HorizontalGroup,
VerticalGroup,
Modal,
LoadingPlaceholder,
TimeRangeInput,
useStyles2,
} from '@grafana/ui';
import {
PanelProps,
toDataFrame,
FieldType,
applyFieldOverrides,
createTheme,
DataFrame,
DataLinkClickEvent,
PanelModel,
DataQuery,
DataSourceSettings,
TimeRange,
OrgRole,
SelectableValue,
AppEvents,
} from '@grafana/data';
import { RefreshEvent } from '@grafana/runtime';
import React, { useState, useEffect } from 'react';
import * as _ from 'lodash';
import PluginState from 'plugin_state';
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 [csvDelimiter, setCsvDelimiter] = useState<string>(',');
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.Viewer) {
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,
csvDelimiter,
};
const token = await PluginState.createGrafanaToken();
// TODO: move this function to API Service
await queryApi('/task', {
method: 'POST',
data: {
task,
url: window.location.toString(),
timeZoneName,
apiKey: token.key,
},
});
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',
cellOptions: { type: '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: 'Download',
type: FieldType.string,
values: _.map(sortedTasks, (task) => {
switch (task.progress?.status) {
case ExportStatus.FINISHED:
if (task.id === taskIdBeingDownloaded) {
return PRELOADER_ICON_BASE_64;
} else {
return DOWNLOAD_ICON_BASE_64;
}
case ExportStatus.ERROR:
return DISABLED_DOWNLOAD_ICON_BASE_64;
case ExportStatus.EXPORTING:
return PRELOADER_ICON_BASE_64;
default:
throw new Error(`Unknown exporting status: ${task.progress?.status}`);
}
}),
config: {
custom: {
filterable: false,
displayMode: 'image',
cellOptions: { type: 'image' },
},
links: [
{
targetBlank: false,
title: 'Download',
url: '#',
onClick: (event: DataLinkClickEvent) => onDownloadClick(event),
},
],
},
},
{
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: '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: 'Delete',
type: FieldType.string,
values: _.map(sortedTasks, (task) => {
switch (task.progress?.status) {
case ExportStatus.FINISHED:
if (task.id === taskIdBeingDownloaded) {
return PRELOADER_ICON_BASE_64;
} else {
return CLOSE_ICON_BASE_64;
}
case ExportStatus.ERROR:
return CLOSE_ICON_BASE_64;
case ExportStatus.EXPORTING:
return PRELOADER_ICON_BASE_64;
default:
throw new Error(`Unknown exporting status: ${task.progress?.status}`);
}
}),
config: {
custom: {
filterable: false,
displayMode: 'image',
cellOptions: { type: 'image' },
},
links: [
{
targetBlank: false,
title: 'Delete',
url: '#',
onClick: (event: DataLinkClickEvent) => onDeleteClick(event),
},
],
},
},
],
});
const dataFrames = applyFieldOverrides({
data: [dataFrame],
fieldConfig: {
overrides: [],
defaults: {},
},
theme: createTheme(),
replaceVariables: (value: string) => value,
});
return dataFrames[0];
}
async function onDeleteClick(event: DataLinkClickEvent): Promise<void> {
const rowIndex = event.origin.rowIndex;
const sortedTasks = _.orderBy(tasks, (task) => task.progress?.time, 'desc');
const taskToDelete = sortedTasks[rowIndex];
if (taskToDelete.progress?.status === ExportStatus.EXPORTING || taskToDelete.id === taskIdBeingDownloaded) {
appEvents.emit(AppEvents.alertWarning, ['Data Exporter', 'Active task can`t be deleted']);
return;
}
await deleteTask(taskToDelete?.id);
const filteredTasks = _.filter(tasks, (task) => task.id !== taskToDelete.id);
setTasks(filteredTasks);
}
async function onDownloadClick(event: DataLinkClickEvent): Promise<void> {
const rowIndex = event.origin.rowIndex;
const sortedTasks = _.orderBy(tasks, (task) => task.progress?.time, 'desc');
const task = sortedTasks[rowIndex];
if (task.progress?.status === ExportStatus.EXPORTING || task.id === taskIdBeingDownloaded) {
appEvents.emit(AppEvents.alertWarning, ['Data Exporter', 'Active task can`t be downloaded']);
return;
}
if (task.progress?.status === ExportStatus.ERROR) {
appEvents.emit(AppEvents.alertWarning, ['Data Exporter', 'Failed task can`t be downloaded']);
return;
}
setTaskIdBeingDownloaded(task.id as string);
await getStaticFile(task?.filename);
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 delimeterOptions: SelectableValue[] = [
{ value: ',', label: 'Comma: ","' },
{ value: ';', label: 'Semicolon: ";"' },
{ value: '\t', label: 'Tabulator: "\\t"' },
];
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 and Editors. </div>
</div>
);
const mainDiv = (
<div>
{tasksDataFrame && <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="space-between" spacing="md">
<HorizontalGroup justify="flex-end" spacing="md">
<span>CSV delimiter:</span>
<Select
options={delimeterOptions}
value={csvDelimiter}
onChange={(el) => {
setCsvDelimiter(el.value);
}}
/>
</HorizontalGroup>
<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;
}
`,
});