Browse Source

Merge branch 'master' into dist

dist
rozetko 2 years ago
parent
commit
563cb497a4
  1. 2
      package.json
  2. 32
      src/components/PluginConfigPage/PluginConfigPage.tsx
  3. 4
      src/components/PluginConfigPage/parts/ConfigurationForm/index.tsx
  4. 4
      src/icons.ts
  5. 250
      src/panels/corpglory-dataexporter-panel/components/Panel.tsx
  6. 115
      src/panels/corpglory-dataexporter-panel/types.ts
  7. 24
      src/plugin_state.ts
  8. 4
      src/services/api_service.ts
  9. 2
      src/types.ts
  10. 6
      src/utils/index.ts

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "corpglory-dataexporter-app", "name": "corpglory-dataexporter-app",
"version": "1.0.0", "version": "1.0.1",
"description": "", "description": "",
"scripts": { "scripts": {
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src", "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src",

32
src/components/PluginConfigPage/PluginConfigPage.tsx

@ -1,15 +1,14 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import { Legend, LoadingPlaceholder } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
// import logo from '../../img/logo.svg';
import PluginState, { PluginConnectedStatusResponse } from '../../plugin_state'; import PluginState, { PluginConnectedStatusResponse } from '../../plugin_state';
import ConfigurationForm from './parts/ConfigurationForm'; import ConfigurationForm from './parts/ConfigurationForm';
import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton'; import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton';
import StatusMessageBlock from './parts/StatusMessageBlock'; import StatusMessageBlock from './parts/StatusMessageBlock';
import { DataExporterPluginConfigPageProps } from 'types'; import { DataExporterPluginConfigPageProps } from '../../types';
import { Legend, LoadingPlaceholder } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import React, { FC, useCallback, useEffect, useState } from 'react';
const PLUGIN_CONFIGURED_QUERY_PARAM = 'pluginConfigured'; const PLUGIN_CONFIGURED_QUERY_PARAM = 'pluginConfigured';
const PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE = 'true'; const PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE = 'true';
@ -71,14 +70,18 @@ export const PluginConfigPage: FC<DataExporterPluginConfigPageProps> = ({
const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]);
const checkConnection = useCallback(async () => { const checkConnection = useCallback(
async (grafanaDataExporterUrl?: string) => {
setCheckingIfPluginIsConnected(true); setCheckingIfPluginIsConnected(true);
setPluginConnectionCheckError(null); setPluginConnectionCheckError(null);
if (!pluginMetaDataExporterApiUrl) {
const backendUrl = grafanaDataExporterUrl || pluginMetaDataExporterApiUrl;
if (!backendUrl) {
setCheckingIfPluginIsConnected(false); setCheckingIfPluginIsConnected(false);
return; return;
} }
const pluginConnectionResponse = await PluginState.checkIfPluginIsConnected(pluginMetaDataExporterApiUrl); const pluginConnectionResponse = await PluginState.checkIfPluginIsConnected(backendUrl);
if (typeof pluginConnectionResponse === 'string') { if (typeof pluginConnectionResponse === 'string') {
setPluginConnectionCheckError(pluginConnectionResponse); setPluginConnectionCheckError(pluginConnectionResponse);
@ -88,7 +91,9 @@ export const PluginConfigPage: FC<DataExporterPluginConfigPageProps> = ({
} }
setCheckingIfPluginIsConnected(false); setCheckingIfPluginIsConnected(false);
}, [pluginMetaDataExporterApiUrl]); },
[pluginMetaDataExporterApiUrl]
);
useEffect(resetQueryParams, [resetQueryParams]); useEffect(resetQueryParams, [resetQueryParams]);
@ -166,10 +171,7 @@ export const PluginConfigPage: FC<DataExporterPluginConfigPageProps> = ({
<Legend>Configure DataExporter</Legend> <Legend>Configure DataExporter</Legend>
{pluginIsConnected ? ( {pluginIsConnected ? (
<> <>
<p> <p>Plugin is connected! You can now go to a dashboard and add the DataExporter panel there.</p>
Plugin is connected! Continue to DataExporter by clicking the{' '}
{/* <img alt="DataExporter Logo" src={logo} width={18} /> icon over there 👈 */}
</p>
<StatusMessageBlock text={`Connected to DataExporter`} /> <StatusMessageBlock text={`Connected to DataExporter`} />
</> </>
) : ( ) : (

4
src/components/PluginConfigPage/parts/ConfigurationForm/index.tsx

@ -16,7 +16,7 @@ import { isEmpty } from 'lodash-es';
const cx = cn.bind(styles); const cx = cn.bind(styles);
type Props = { type Props = {
onSuccessfulSetup: () => void; onSuccessfulSetup: (grafanaDataExporterUrl: string) => void;
defaultDataExporterApiUrl: string; defaultDataExporterApiUrl: string;
}; };
@ -68,7 +68,7 @@ const ConfigurationForm: FC<Props> = ({ onSuccessfulSetup, defaultDataExporterAp
const errorMsg = await PluginState.installPlugin(dataExporterApiUrl); const errorMsg = await PluginState.installPlugin(dataExporterApiUrl);
if (!errorMsg) { if (!errorMsg) {
onSuccessfulSetup(); onSuccessfulSetup(dataExporterApiUrl);
} else { } else {
setSetupErrorMsg(errorMsg); setSetupErrorMsg(errorMsg);
setFormLoading(false); setFormLoading(false);

4
src/icons.ts

@ -1,7 +1,7 @@
export const CLOSE_ICON_BASE_64 = export const CLOSE_ICON_BASE_64 =
'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACJSURBVHgB7ZPRCcAgDESv0lWc0zhC53OIjmAVUpCSamL76YEoyfFIiAGWfldK6SwnKHyhep9xJ3iPcqgH5RyxV1VlBWYJypXVHMEiCaqBbRhAy3W3B76j954wq6ZSVZsOY+WXt6i9l2ymGTlUq0VpOcIqaQC96Zth01DN1zBBefVI4SNp9Za+6wLcH6DKFrfpxgAAAABJRU5ErkJggg=='; 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACJSURBVHgB7ZPRCcAgDESv0lWc0zhC53OIjmAVUpCSamL76YEoyfFIiAGWfldK6SwnKHyhep9xJ3iPcqgH5RyxV1VlBWYJypXVHMEiCaqBbRhAy3W3B76j954wq6ZSVZsOY+WXt6i9l2ymGTlUq0VpOcIqaQC96Zth01DN1zBBefVI4SNp9Za+6wLcH6DKFrfpxgAAAABJRU5ErkJggg==';
export const DOWNLOAD_ICON_BASE_64 = export const OPTIONS_ICON_BASE_64 =
'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADlSURBVHgB3ZPNDYJAEIVnN96lBA7A2RIsATuQiiwBSrAD7MAjCZBACXqFwPomMRtEwMVwgZeQhfn5mNnMEG1CaZoeiqKwTWJ3JkFCiEtVVU+8+r9iJRlKSrk3iqOFtWJglmXHf3yDwCRJbBxxnudh34cRYluMMbKGcgWNCIlnjEuIJ1JK2WzDWeKb7YHjONEsYBf6kTABY+mWuYX+3Xiex9UFU7D3FllfwLqueQvi/h8ZCtBprDLY703T6A0yWj2ArmSoxedQV4jSSz5xj4pmCi0/NKfrwNz5bdtaNENciOu6N1qNXhzZXHMb9Q+nAAAAAElFTkSuQmCC'; 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAMAAAC6V+0/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAS1BMVEUAAADH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0NnH0Nn///+uWGxHAAAAF3RSTlMAABI4MAgNNbzy7JcQG6zvoPxPbavwDHoS9oEAAAABYktHRBibaYUeAAAACXBIWXMAAABgAAAAYADwa0LPAAAAB3RJTUUH5wEUFy4lfOQAfAAAAFdJREFUGNPFjkkKwCAQBEcd912TzP9/GglIQvBuXQoamm6ArbCHv7hAOaS0VkMSBWeAxjofIKacU4TgnTUIhYhqgz5EHVodKutw1o98vvU5dH2Hlpe2cgMuNAUd58WuNgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMy0wMS0yMFQyMDo0NjozNyswMzowMDMOisYAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjMtMDEtMjBUMjA6NDY6MzcrMDM6MDBCUzJ6AAAAAElFTkSuQmCC';
export const UNSELECT_ICON_BASE_64 = export const UNSELECT_ICON_BASE_64 =
'PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMC41IiB5PSIwLjUiIHdpZHRoPSIxNSIgaGVpZ2h0PSIxNSIgcng9IjEuNSIgZmlsbD0iIzExMTIxNiIgc3Ryb2tlPSIjMkQyRTM0Ii8+Cjwvc3ZnPgo='; 'PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMC41IiB5PSIwLjUiIHdpZHRoPSIxNSIgaGVpZ2h0PSIxNSIgcng9IjEuNSIgZmlsbD0iIzExMTIxNiIgc3Ryb2tlPSIjMkQyRTM0Ii8+Cjwvc3ZnPgo=';
export const SELECT_ICON_BASE_64 = export const SELECT_ICON_BASE_64 =

250
src/panels/corpglory-dataexporter-panel/components/Panel.tsx

@ -1,12 +1,20 @@
import { PanelOptions, ExportTask, DashboardQuery, DatasourceType, ExportStatus, PanelStatus } from '../types'; import {
PanelOptions,
import { convertTimestampToDate, convertTimeZoneTypeToName, getDashboardUid } from '../../../utils'; ExportTask,
import { CLOSE_ICON_BASE_64, DOWNLOAD_ICON_BASE_64, SELECT_ICON_BASE_64, UNSELECT_ICON_BASE_64 } from '../../../icons'; DashboardQuery,
DatasourceType,
ExportStatus,
PanelStatus,
Dashboard,
} from '../types';
import { convertTimestampToDate, convertTimeZoneTypeToName, getCurrentDashboardUid } from '../../../utils';
import { CLOSE_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 { deleteTask, getStaticFile, getTasks, queryApi } from '../../../services/api_service';
import { getDashboardByUid, getDatasources } from '../../../services/grafana_backend_service'; import { getDashboardByUid, getDatasources } from '../../../services/grafana_backend_service';
import { contextSrv } from 'grafana/app/core/core'; import { appEvents, contextSrv } from 'grafana/app/core/core';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { import {
@ -32,6 +40,7 @@ import {
DataSourceSettings, DataSourceSettings,
TimeRange, TimeRange,
OrgRole, OrgRole,
AppEvents,
} from '@grafana/data'; } from '@grafana/data';
import { RefreshEvent } from '@grafana/runtime'; import { RefreshEvent } from '@grafana/runtime';
@ -44,8 +53,7 @@ const APP_ID = 'corpglory-dataexporter-app';
interface Props extends PanelProps<PanelOptions> {} interface Props extends PanelProps<PanelOptions> {}
export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) { export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
// TODO: Dashboard type const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [dashboard, setDashboard] = useState<any | null>(null);
const [datasources, setDatasources] = useState<DataSourceSettings[] | null>(null); const [datasources, setDatasources] = useState<DataSourceSettings[] | null>(null);
const [tasks, setTasks] = useState<ExportTask[] | null>(null); const [tasks, setTasks] = useState<ExportTask[] | null>(null);
@ -59,17 +67,24 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
const [selectedTimeRange, setTimeRange] = useState<TimeRange>(timeRange); const [selectedTimeRange, setTimeRange] = useState<TimeRange>(timeRange);
const [panelStatus, setPanelStatus] = useState<PanelStatus>(PanelStatus.LOADING); 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); const timeZoneName = convertTimeZoneTypeToName(timeZone);
if (contextSrv.user.orgRole !== OrgRole.Admin) { if (contextSrv.user.orgRole !== OrgRole.Admin) {
// TODO: it shouldn't be overriten setPanelStatusWithValidate(PanelStatus.PERMISSION_ERROR);
setPanelStatus(PanelStatus.PERMISSION_ERROR);
} }
useEffect(() => { useEffect(() => {
async function getCurrentDashboard(): Promise<any> { async function getCurrentDashboard(): Promise<any> {
const currentDashboardUid = getDashboardUid(window.location.toString()); const currentDashboardUid = getCurrentDashboardUid();
return getDashboardByUid(currentDashboardUid); return getDashboardByUid(currentDashboardUid);
} }
@ -86,12 +101,30 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (tasks === null) {
return;
}
const dataFrame = getDataFrameForTaskTable(tasks);
setTasksDataFrame(dataFrame);
}, [tasks]); // 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) { if (!dashboard || !datasources) {
console.warn(`Can't update queries if there is no dashboard or datasources`);
return; return;
} }
dashboard.panels.forEach((panel: PanelModel) => {
const queries: DashboardQuery[] = [];
const queries: DashboardQuery[] = [];
dashboard.panels?.forEach((panel: PanelModel) => {
// @ts-ignore // @ts-ignore
if (panel.type === PANEL_ID) { if (panel.type === PANEL_ID) {
return; return;
@ -108,47 +141,32 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
} }
queries.push({ selected: false, target, panel, datasource }); queries.push({ selected: false, target, panel, datasource });
}); });
setQueries(queries);
}); });
}, [dashboard, datasources]); // eslint-disable-line react-hooks/exhaustive-deps setQueries(queries);
useEffect(() => {
if (tasks === null) {
return;
} }
const dataFrame = getDataFrameForTaskTable(tasks);
setTasksDataFrame(dataFrame);
}, [tasks]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(refresh, []); // eslint-disable-line react-hooks/exhaustive-deps function fetchTasks(): void {
const dashboardUid = getCurrentDashboardUid();
useEffect(() => {
if (queries === null) {
return;
}
setQueriesDataFrame(getDataFrameForQueriesTable(queries));
}, [queries]); // eslint-disable-line react-hooks/exhaustive-deps
function refresh(): void { getTasks(dashboardUid)
getTasks()
.then((tasks) => { .then((tasks) => {
setTasks(tasks); setTasks(tasks);
setPanelStatus(PanelStatus.OK); setPanelStatusWithValidate(PanelStatus.OK);
for (let task of tasks) { for (let task of tasks) {
if (task.progress?.status === ExportStatus.EXPORTING) { if (task.progress?.status === ExportStatus.EXPORTING) {
setTimeout(refresh, 1000); setTimeout(fetchTasks, 1000);
return; return;
} }
} }
}) })
.catch((err) => { .catch((err) => {
setPanelStatus(PanelStatus.DATASOURCE_ERROR); setPanelStatusWithValidate(PanelStatus.DATASOURCE_ERROR);
console.error('some error', err); console.error('some error', err);
setErrorMessage(`${err.name}: ${err.message}`);
}); });
} }
eventBus.subscribe(RefreshEvent, refresh); eventBus.subscribe(RefreshEvent, fetchTasks);
function getDatasourceByUid(uid?: string): DataSourceSettings | undefined { function getDatasourceByUid(uid?: string): DataSourceSettings | undefined {
if (_.isNil(uid)) { if (_.isNil(uid)) {
@ -172,7 +190,10 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
const selectedQueries = _.filter(queries, (query: DashboardQuery) => query.selected); const selectedQueries = _.filter(queries, (query: DashboardQuery) => query.selected);
const timerange: [number, number] = [selectedTimeRange.from.unix(), selectedTimeRange.to.unix()]; const timerange: [number, number] = [selectedTimeRange.from.unix(), selectedTimeRange.to.unix()];
const dashboardUid = getCurrentDashboardUid();
const task: ExportTask = { const task: ExportTask = {
dashboardUid,
// @ts-ignore // @ts-ignore
username: contextSrv.user.name, username: contextSrv.user.name,
timeRange: { timeRange: {
@ -191,7 +212,7 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
}, },
}); });
refresh(); fetchTasks();
onCloseModal(); onCloseModal();
unselectAllQueries(); unselectAllQueries();
@ -205,6 +226,7 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
} }
function openDatasourceModal(): void { function openDatasourceModal(): void {
updateQueries();
setTimeRange(timeRange); setTimeRange(timeRange);
setModalVisibility(true); setModalVisibility(true);
} }
@ -271,80 +293,75 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
} }
function getDataFrameForTaskTable(tasks: ExportTask[]): DataFrame { function getDataFrameForTaskTable(tasks: ExportTask[]): DataFrame {
const sortedTasks = _.orderBy(tasks, (task) => task.progress?.time, 'desc');
const dataFrame = toDataFrame({ const dataFrame = toDataFrame({
name: 'A', name: 'A',
fields: [ fields: [
{ {
name: 'Time', name: 'Status Updated At',
type: FieldType.number, type: FieldType.number,
values: _.map(tasks, (task) => convertTimestampToDate(task.progress?.time)), 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', name: 'User',
type: FieldType.string, type: FieldType.string,
values: _.map(tasks, (task) => task.username), values: _.map(sortedTasks, (task) => task.username),
}, },
{ {
name: 'Datasource', name: 'Datasource',
type: FieldType.string, type: FieldType.string,
values: _.map(tasks, (task) => task.queries.map((query) => query.datasource?.name).join(',')), values: _.map(sortedTasks, (task) => task.queries.map((query) => query.datasource?.name).join(',')),
}, },
{ {
name: 'Exported Rows', name: 'Exported Rows',
type: FieldType.number, type: FieldType.number,
values: _.map(tasks, (task) => task.progress?.exportedRowsCount), values: _.map(sortedTasks, (task) => task.progress?.exportedRowsCount),
}, },
{ {
name: 'Progress', name: 'Progress',
type: FieldType.string, type: FieldType.string,
values: _.map(tasks, (task) => `${((task.progress?.progress || 0) * 100).toFixed(0)}%`), values: _.map(sortedTasks, (task) => `${((task.progress?.progress || 0) * 100).toFixed(0)}%`),
}, },
{ {
name: 'Status', name: 'Status',
type: FieldType.string, type: FieldType.string,
values: _.map(tasks, (task) => task.progress?.status), values: _.map(sortedTasks, (task) => task.progress?.status),
}, },
{ {
name: 'Error', name: 'Error',
type: FieldType.string, type: FieldType.string,
values: _.map(tasks, (task) => task.progress?.errorMessage || '-'), values: _.map(sortedTasks, (task) => task.progress?.errorMessage || '-'),
}, },
{ {
name: 'Download CSV', name: 'Actions',
type: FieldType.string, type: FieldType.string,
values: _.map(tasks, () => `data:image/png;base64,${DOWNLOAD_ICON_BASE_64}`), values: _.map(sortedTasks, (task) => {
config: { switch (task.progress?.status) {
custom: { case ExportStatus.FINISHED:
filterable: false, return `data:image/png;base64,${OPTIONS_ICON_BASE_64}`;
displayMode: 'image', case ExportStatus.ERROR:
}, return `data:image/png;base64,${CLOSE_ICON_BASE_64}`;
links: [ case ExportStatus.EXPORTING:
{ return ``;
targetBlank: false, default:
title: 'Download', throw new Error(`Unknown exporting status: ${task.progress?.status}`);
url: '#', }
onClick: (event: DataLinkClickEvent) => onDownloadClick(event), }),
},
],
},
},
{
name: 'Delete task',
type: FieldType.string,
values: _.map(tasks, () => `data:image/png;base64,${CLOSE_ICON_BASE_64}`),
config: { config: {
custom: { custom: {
filterable: false, filterable: false,
displayMode: 'image', displayMode: 'image',
}, },
links: [
{
targetBlank: false,
title: 'Delete',
url: '#',
onClick: (event: DataLinkClickEvent) => onDeleteClick(event),
},
],
}, },
}, },
], ],
@ -359,22 +376,57 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
theme: createTheme(), theme: createTheme(),
replaceVariables: (value: string) => value, 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]; return dataFrames[0];
} }
async function onDeleteClick(e: DataLinkClickEvent): Promise<void> { async function onDeleteClick(taskToDelete: ExportTask): Promise<void> {
const rowIndex = e.origin.rowIndex; await deleteTask(taskToDelete?.id);
const task = _.find(tasks, (task, idx) => idx === rowIndex);
await deleteTask(task?.id);
const filteredTasks = _.filter(tasks, (task, idx) => idx !== rowIndex); const filteredTasks = _.filter(tasks, (task) => task.id !== taskToDelete.id);
setTasks(filteredTasks); setTasks(filteredTasks);
} }
function onDownloadClick(e: DataLinkClickEvent): void { function onDownloadClick(task: ExportTask): void {
const rowIndex = e.origin.rowIndex; appEvents.emit(AppEvents.alertSuccess, ['CSV has started downloading...']);
const task = _.find(tasks, (task, idx) => idx === rowIndex);
getStaticFile(task?.id); getStaticFile(task?.id);
} }
@ -396,34 +448,33 @@ export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
<div> <div>
<p>Datasource is unavailable.</p> <p>Datasource is unavailable.</p>
<div> <div>
Click <a href={`/plugins/${APP_ID}`}>here</a> to configure DataExporter. If you have not setup the plugin click
<a className={styles.customLink} href={`/plugins/${APP_ID}`}>
here
</a>
to configure DataExporter.
</div> </div>
{/* TODO: display error message? */} <p style={{ marginTop: '16px' }}>{errorMessage}</p>
</div> </div>
); );
const permissionErrorDiv = ( const permissionErrorDiv = (
<div> <div>
<p>Permission Error.</p> <p> Permission Error. </p>
<div> DataExporter panel availabel only for Admins </div> <div> DataExporter panel available only for Admins. </div>
</div> </div>
); );
const mainDiv = ( const mainDiv = (
<div> <div>
<Table width={width} height={height - 40} data={tasksDataFrame as DataFrame} /> <Table width={width} height={height - 40} data={tasksDataFrame as DataFrame} />
<HorizontalGroup justify="flex-end"> <HorizontalGroup justify="flex-end">
<Button <Button variant="primary" icon="plus" style={{ marginTop: '8px' }} onClick={openDatasourceModal}>
variant="primary"
aria-label="Rich history button"
icon="plus"
style={{ marginTop: '8px' }}
onClick={openDatasourceModal}
>
Add Task Add Task
</Button> </Button>
<Modal title="Select Queries" isOpen={isModalOpen} onDismiss={onCloseModal} className={styles.calendarModal}> <Modal title="Select Queries" isOpen={isModalOpen} onDismiss={onCloseModal} className={styles.calendarModal}>
{queriesDataFrame === null ? ( {queriesDataFrame === null ? (
// TODO: if datasource responds with error, display the error <div>
<LoadingPlaceholder text="Loading..."></LoadingPlaceholder> <p>There are no queries to export.</p>
</div>
) : ( ) : (
<div> <div>
<VerticalGroup spacing="xs"> <VerticalGroup spacing="xs">
@ -480,4 +531,11 @@ const getStyles = () => ({
z-index: 1061; z-index: 1061;
} }
`, `,
customLink: css`
color: #6e9fff;
margin: 0 4px;
&:hover {
text-decoration: underline;
}
`,
}); });

115
src/panels/corpglory-dataexporter-panel/types.ts

@ -33,6 +33,7 @@ export type ExportProgress = {
}; };
export type ExportTask = { export type ExportTask = {
dashboardUid: string;
username: string; username: string;
queries: DashboardQuery[]; queries: DashboardQuery[];
timeRange: { timeRange: {
@ -49,3 +50,117 @@ export type DashboardQuery = {
panel: PanelModel; panel: PanelModel;
datasource: DataSourceSettings; datasource: DataSourceSettings;
}; };
export interface Dashboard {
/**
* TODO docs
*/
annotations?: {
list: any[];
};
/**
* Description of dashboard.
*/
description?: string;
/**
* Whether a dashboard is editable or not.
*/
editable: boolean;
/**
* TODO docs
*/
fiscalYearStartMonth?: number;
gnetId?: string;
graphTooltip: any;
/**
* Unique numeric identifier for the dashboard.
* TODO must isolate or remove identifiers local to a Grafana instance...?
*/
id?: number;
/**
* TODO docs
*/
links?: any[];
/**
* TODO docs
*/
liveNow?: boolean;
panels?: PanelModel[];
/**
* TODO docs
*/
refresh?: string | false;
/**
* Version of the JSON schema, incremented each time a Grafana update brings
* changes to said schema.
* TODO this is the existing schema numbering system. It will be replaced by Thema's themaVersion
*/
schemaVersion: number;
/**
* Theme of dashboard.
*/
style: 'light' | 'dark';
/**
* Tags associated with dashboard.
*/
tags?: string[];
/**
* TODO docs
*/
templating?: {
list: any[];
};
/**
* Time range for dashboard, e.g. last 6 hours, last 7 days, etc
*/
time?: {
from: string;
to: string;
};
/**
* TODO docs
* TODO this appears to be spread all over in the frontend. Concepts will likely need tidying in tandem with schema changes
*/
timepicker?: {
/**
* Whether timepicker is collapsed or not.
*/
collapse: boolean;
/**
* Whether timepicker is enabled or not.
*/
enable: boolean;
/**
* Whether timepicker is visible or not.
*/
hidden: boolean;
/**
* Selectable intervals for auto-refresh.
*/
refresh_intervals: string[];
/**
* TODO docs
*/
time_options: string[];
};
/**
* Timezone of dashboard,
*/
timezone?: 'browser' | 'utc' | string;
/**
* Title of dashboard.
*/
title?: string;
/**
* Unique dashboard identifier that can be generated by anyone. string (8-40)
*/
uid?: string;
/**
* Version of the dashboard, incremented each time the dashboard is updated.
*/
version?: number;
/**
* TODO docs
*/
weekStart?: string;
}

24
src/plugin_state.ts

@ -14,10 +14,7 @@ export type UpdateGrafanaPluginSettingsProps = {
secureJsonData?: Partial<DataExporterPluginMetaSecureJSONData>; secureJsonData?: Partial<DataExporterPluginMetaSecureJSONData>;
}; };
type InstallPluginResponse<DataExporterAPIResponse = any> = Pick< type InstallPluginResponse<DataExporterAPIResponse = any> = Pick<DataExporterPluginMetaSecureJSONData, 'apiToken'> & {
DataExporterPluginMetaSecureJSONData,
'grafanaToken'
> & {
dataExporterAPIResponse: DataExporterAPIResponse; dataExporterAPIResponse: DataExporterAPIResponse;
}; };
@ -125,13 +122,14 @@ class PluginState {
static timeout = (pollCount: number) => new Promise((resolve) => setTimeout(resolve, 10 * 2 ** pollCount)); static timeout = (pollCount: number) => new Promise((resolve) => setTimeout(resolve, 10 * 2 ** pollCount));
static connectBackend = async <RT>(): Promise<InstallPluginResponse<RT>> => { static connectBackend = async <RT>(): Promise<InstallPluginResponse<RT>> => {
// TODO: try to disable success alerts from Grafana API const { key: apiToken } = await this.createGrafanaToken();
const { key: grafanaToken } = await this.createGrafanaToken(); await this.updateGrafanaPluginSettings({ secureJsonData: { apiToken } });
await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } }); // TODO: display alert on error
const dataExporterAPIResponse = await queryApi<RT>(`/connect`, { const dataExporterAPIResponse = await queryApi<RT>(`/connect`, {
method: 'POST', method: 'POST',
data: { apiToken, url: window.location.toString() },
}); });
return { grafanaToken, dataExporterAPIResponse }; return { apiToken, dataExporterAPIResponse };
}; };
static installPlugin = async (dataExporterApiUrl: string): Promise<string | null> => { static installPlugin = async (dataExporterApiUrl: string): Promise<string | null> => {
@ -158,14 +156,14 @@ class PluginState {
// Step 3. reprovision the Grafana plugin settings, storing information that we get back from DataExporter's backend // Step 3. reprovision the Grafana plugin settings, storing information that we get back from DataExporter's backend
try { try {
const { grafanaToken } = pluginInstallationDataExporterResponse; const { apiToken } = pluginInstallationDataExporterResponse;
await this.updateGrafanaPluginSettings({ await this.updateGrafanaPluginSettings({
jsonData: { jsonData: {
dataExporterApiUrl, dataExporterApiUrl,
}, },
secureJsonData: { secureJsonData: {
grafanaToken, apiToken,
}, },
}); });
} catch (e) { } catch (e) {
@ -179,11 +177,13 @@ class PluginState {
dataExporterApiUrl: string dataExporterApiUrl: string
): Promise<PluginConnectedStatusResponse | string> => { ): Promise<PluginConnectedStatusResponse | string> => {
try { try {
const resp = await queryApi<PluginConnectedStatusResponse>(`/status`, { const resp = await queryApi<PluginConnectedStatusResponse>(`/connect`, {
method: 'GET', method: 'GET',
params: { url: window.location.toString() },
}); });
// TODO: check if the server version is compatible with the plugin // TODO: check if the server version is compatible with the plugin
// TODO: remove configuration if backend says that api key doesn't work
if (resp.version) { if (resp.version) {
return resp; return resp;
} else { } else {
@ -204,7 +204,7 @@ class PluginState {
dataExporterApiUrl: null, dataExporterApiUrl: null,
}; };
const secureJsonData: Required<DataExporterPluginMetaSecureJSONData> = { const secureJsonData: Required<DataExporterPluginMetaSecureJSONData> = {
grafanaToken: null, apiToken: null,
}; };
return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false); return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false);

4
src/services/api_service.ts

@ -43,8 +43,8 @@ export const queryApi = async <RT = any>(path: string, config: RequestConfig) =>
return response.data as RT; return response.data as RT;
}; };
export async function getTasks(): Promise<ExportTask[]> { export async function getTasks(dashboardUid: string): Promise<ExportTask[]> {
return queryApi<ExportTask[]>('/task', {}); return queryApi<ExportTask[]>('/task', { params: { dashboardUid } });
} }
export async function deleteTask(taskId?: string): Promise<void> { export async function deleteTask(taskId?: string): Promise<void> {

2
src/types.ts

@ -5,7 +5,7 @@ export type DataExporterPluginMetaJSONData = {
}; };
export type DataExporterPluginMetaSecureJSONData = { export type DataExporterPluginMetaSecureJSONData = {
grafanaToken: string | null; apiToken: string | null;
}; };
export type AppRootProps = BaseAppRootProps<DataExporterPluginMetaJSONData>; export type AppRootProps = BaseAppRootProps<DataExporterPluginMetaJSONData>;

6
src/utils/index.ts

@ -6,10 +6,12 @@ export function openNotification(message: React.ReactNode) {
appEvents.emit(AppEvents.alertSuccess, [message]); appEvents.emit(AppEvents.alertSuccess, [message]);
} }
export function getDashboardUid(url: string): string { export function getCurrentDashboardUid(): string {
const url = window.location.toString();
const matches = new URL(url).pathname.match(/\/d\/([^/]+)/); const matches = new URL(url).pathname.match(/\/d\/([^/]+)/);
if (!matches) { if (!matches) {
throw new Error(`Couldn't parse uid from ${url}`); throw new Error(`Can't get current dashboard uid. If it's a new dashboard, please save it first.`);
} else { } else {
return matches[1]; return matches[1];
} }

Loading…
Cancel
Save