diff --git a/src/panels/corpglory-dataexporter-panel/components/Panel.tsx b/src/panels/corpglory-dataexporter-panel/components/Panel.tsx index 1af10d9..cb45844 100644 --- a/src/panels/corpglory-dataexporter-panel/components/Panel.tsx +++ b/src/panels/corpglory-dataexporter-panel/components/Panel.tsx @@ -1,6 +1,6 @@ -import { PanelOptions, TaskTableRowConfig, QueryTableRowConfig, DatasourceType } from '../types'; +import { PanelOptions, ExportTask, DashboardQuery, DatasourceType, ExportStatus, PanelStatus } from '../types'; -import { convertTimestampToDate, getDashboardUid } from '../../../utils'; +import { convertTimestampToDate, convertTimeZoneTypeToName, getDashboardUid } from '../../../utils'; import { CLOSE_ICON_BASE_64, DOWNLOAD_ICON_BASE_64, SELECT_ICON_BASE_64, UNSELECT_ICON_BASE_64 } from '../../../icons'; import { deleteTask, getStaticFile, getTasks, queryApi } from '../../../services/api_service'; @@ -31,21 +31,25 @@ import { 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 {} -export function Panel({ width, height, timeRange, eventBus }: Props) { +export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) { // TODO: Dashboard type const [dashboard, setDashboard] = useState(null); const [datasources, setDatasources] = useState(null); - const [tasks, setTasks] = useState(null); - const [queries, setQueries] = useState(null); + const [tasks, setTasks] = useState(null); + const [queries, setQueries] = useState(null); const [tasksDataFrame, setTasksDataFrame] = useState(null); const [queriesDataFrame, setQueriesDataFrame] = useState(null); @@ -54,6 +58,15 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { const [selectedTimeRange, setTimeRange] = useState(timeRange); + const [panelStatus, setPanelStatus] = useState(PanelStatus.LOADING); + + const timeZoneName = convertTimeZoneTypeToName(timeZone); + + if (contextSrv.user.orgRole !== OrgRole.Admin) { + // TODO: it shouldn't be overriten + setPanelStatus(PanelStatus.PERMISSION_ERROR); + } + useEffect(() => { async function getCurrentDashboard(): Promise { const currentDashboardUid = getDashboardUid(window.location.toString()); @@ -77,11 +90,10 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { return; } dashboard.panels.forEach((panel: PanelModel) => { - const queries: QueryTableRowConfig[] = []; + const queries: DashboardQuery[] = []; // @ts-ignore - // TODO: move plugin id to const - if (panel.type === 'corpglory-dataexporter-panel') { + if (panel.type === PANEL_ID) { return; } @@ -90,12 +102,11 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { } panel.targets?.forEach((target: DataQuery) => { - console.log('uid', target.datasource?.uid); const datasource = getDatasourceByUid(target.datasource?.uid); if (!datasource) { return; } - queries.push({ ...target, selected: false, panel, datasource }); + queries.push({ selected: false, target, panel, datasource }); }); setQueries(queries); @@ -123,15 +134,18 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { getTasks() .then((tasks) => { setTasks(tasks); + setPanelStatus(PanelStatus.OK); for (let task of tasks) { - // TODO: ExportStatus enum - if (task.status === 'exporting') { + if (task.progress?.status === ExportStatus.EXPORTING) { setTimeout(refresh, 1000); return; } } }) - .catch((err) => console.error(err)); + .catch((err) => { + setPanelStatus(PanelStatus.DATASOURCE_ERROR); + console.error('some error', err); + }); } eventBus.subscribe(RefreshEvent, refresh); @@ -154,20 +168,26 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { return datasource; } - async function onAddTaskClick(): Promise { - const selectedQueries = _.filter(queries, (query: QueryTableRowConfig) => query.selected); - // TODO: timerange picker + async function onAddTaskClick(timeZoneName: string): Promise { + const selectedQueries = _.filter(queries, (query: DashboardQuery) => query.selected); const timerange: [number, number] = [selectedTimeRange.from.unix(), selectedTimeRange.to.unix()]; + + const task: ExportTask = { + // @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: { - from: timerange[0] * 1000, - to: timerange[1] * 1000, - // @ts-ignore - username: contextSrv.user.name, - tasks: selectedQueries, + task, url: window.location.toString(), + timeZoneName, }, }); @@ -181,10 +201,11 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { if (queries === null) { return; } - setQueries(queries.map((query: QueryTableRowConfig) => ({ ...query, selected: false }))); + setQueries(queries.map((query: DashboardQuery) => ({ ...query, selected: false }))); } function openDatasourceModal(): void { + setTimeRange(timeRange); setModalVisibility(true); } @@ -193,7 +214,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { unselectAllQueries(); } - function getDataFrameForQueriesTable(configs: QueryTableRowConfig[]): DataFrame { + function getDataFrameForQueriesTable(queries: DashboardQuery[]): DataFrame { const dataFrame = toDataFrame({ name: 'A', fields: [ @@ -201,8 +222,8 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { name: 'Select', type: FieldType.string, values: _.map( - configs, - (config) => `data:image/svg+xml;base64,${config.selected ? SELECT_ICON_BASE_64 : UNSELECT_ICON_BASE_64}` + queries, + (query) => `data:image/svg+xml;base64,${query.selected ? SELECT_ICON_BASE_64 : UNSELECT_ICON_BASE_64}` ), config: { custom: { @@ -222,17 +243,17 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { { name: 'Panel', type: FieldType.string, - values: _.map(configs, (config) => config.panel.title), + values: _.map(queries, (query) => query.panel.title), }, { name: 'RefId', type: FieldType.string, - values: _.map(configs, (config) => config.refId), + values: _.map(queries, (query) => query.target.refId), }, { name: 'Datasource', type: FieldType.string, - values: _.map(configs, (config) => config.datasource.name), + values: _.map(queries, (query) => query.datasource.name), }, ], }); @@ -249,44 +270,49 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { return dataFrames[0]; } - function getDataFrameForTaskTable(configs: TaskTableRowConfig[]): DataFrame { + function getDataFrameForTaskTable(tasks: ExportTask[]): DataFrame { const dataFrame = toDataFrame({ name: 'A', fields: [ { name: 'Time', type: FieldType.number, - values: _.map(configs, (config) => convertTimestampToDate(config.timestamp)), + values: _.map(tasks, (task) => convertTimestampToDate(task.progress?.time)), }, { name: 'User', type: FieldType.string, - values: _.map(configs, (config) => config.username), + values: _.map(tasks, (task) => task.username), }, { name: 'Datasource', type: FieldType.string, - values: _.map(configs, (config) => getDatasourceByUid(config.datasourceRef?.uid)?.name), + values: _.map(tasks, (task) => task.queries.map((query) => query.datasource?.name).join(',')), }, { name: 'Exported Rows', type: FieldType.number, - values: _.map(configs, (config) => config.rowsCount), + values: _.map(tasks, (task) => task.progress?.exportedRowsCount), }, { name: 'Progress', type: FieldType.string, - values: _.map(configs, (config) => `${(config.progress * 100).toFixed(0)}%`), + values: _.map(tasks, (task) => `${((task.progress?.progress || 0) * 100).toFixed(0)}%`), }, { name: 'Status', type: FieldType.string, - values: _.map(configs, (config) => config.status), + values: _.map(tasks, (task) => task.progress?.status), + }, + { + name: 'Error', + type: FieldType.string, + values: _.map(tasks, (task) => task.progress?.errorMessage || '-'), }, { name: 'Download CSV', type: FieldType.string, - values: _.map(configs, () => `data:image/png;base64,${DOWNLOAD_ICON_BASE_64}`), + values: _.map(tasks, () => `data:image/png;base64,${DOWNLOAD_ICON_BASE_64}`), config: { custom: { filterable: false, @@ -305,7 +331,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { { name: 'Delete task', type: FieldType.string, - values: _.map(configs, () => `data:image/png;base64,${CLOSE_ICON_BASE_64}`), + values: _.map(tasks, () => `data:image/png;base64,${CLOSE_ICON_BASE_64}`), config: { custom: { filterable: false, @@ -340,7 +366,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { const rowIndex = e.origin.rowIndex; const task = _.find(tasks, (task, idx) => idx === rowIndex); - await deleteTask(task?.filename); + await deleteTask(task?.id); const filteredTasks = _.filter(tasks, (task, idx) => idx !== rowIndex); setTasks(filteredTasks); @@ -349,7 +375,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { function onDownloadClick(e: DataLinkClickEvent): void { const rowIndex = e.origin.rowIndex; const task = _.find(tasks, (task, idx) => idx === rowIndex); - getStaticFile(task?.filename); + getStaticFile(task?.id); } function onDatasourceSelectClick(e: DataLinkClickEvent): void { @@ -364,65 +390,86 @@ export function Panel({ width, height, timeRange, eventBus }: Props) { const styles = useStyles2(getStyles); - return ( + const loadingDiv = ; + // TODO: add styles + const datasourceErrorDiv = ( +
+

Datasource is unavailable.

+
+ Click here to configure DataExporter. +
+ {/* TODO: display error message? */} +
+ ); + const permissionErrorDiv = (
- {tasksDataFrame === null ? ( - // TODO: if datasource responds with error, display the error - - ) : ( -
- - - - - {queriesDataFrame === null ? ( - // TODO: if datasource responds with error, display the error - - ) : ( -
- - - { - setTimeRange(newTimeRange); - }} - /> - -
- - - - - - )} - - - - )} +

Permission Error.

+
DataExporter panel availabel only for Admins
); + const mainDiv = ( +
+
+ + + + {queriesDataFrame === null ? ( + // TODO: if datasource responds with error, display the error + + ) : ( +
+ + + { + setTimeRange(newTimeRange); + }} + /> + +
+ + + + + + )} + + + + ); + + 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
{renderSwitch(panelStatus)}
; } const getStyles = () => ({ diff --git a/src/panels/corpglory-dataexporter-panel/types.ts b/src/panels/corpglory-dataexporter-panel/types.ts index c83ef92..1af9a8d 100644 --- a/src/panels/corpglory-dataexporter-panel/types.ts +++ b/src/panels/corpglory-dataexporter-panel/types.ts @@ -1,23 +1,7 @@ -import { DataQuery, DataSourceRef, DataSourceSettings, PanelModel } from '@grafana/data'; +import { DataQuery, DataSourceSettings, PanelModel } from '@grafana/data'; export interface PanelOptions {} -export type TaskTableRowConfig = { - timestamp: number; - username: string; - datasourceRef: DataSourceRef; - rowsCount: number; - progress: number; - status: string; - filename?: string; -}; - -export type QueryTableRowConfig = Omit & { - selected: boolean; - panel: PanelModel; - datasource: DataSourceSettings; -}; - export enum DatasourceType { INFLUXDB = 'influxdb', GRAPHITE = 'graphite', @@ -26,3 +10,42 @@ export enum DatasourceType { ELASTICSEARCH = 'elasticsearch', MYSQL = 'mysql', } + +export enum PanelStatus { + LOADING = 'Loading', + DATASOURCE_ERROR = 'Datasource Error', + PERMISSION_ERROR = 'Permission Error', + OK = 'Ok', +} + +export enum ExportStatus { + EXPORTING = 'exporting', + FINISHED = 'finished', + ERROR = 'error', +} + +export type ExportProgress = { + time: number; + exportedRowsCount: number; + progress: number; + status: ExportStatus; + errorMessage?: string; +}; + +export type ExportTask = { + username: string; + queries: DashboardQuery[]; + timeRange: { + from: number; + to: number; + }; + progress?: ExportProgress; + id?: string; +}; + +export type DashboardQuery = { + selected: boolean; + target: DataQuery; + panel: PanelModel; + datasource: DataSourceSettings; +}; diff --git a/src/services/api_service.ts b/src/services/api_service.ts index a803327..d12acd0 100644 --- a/src/services/api_service.ts +++ b/src/services/api_service.ts @@ -1,4 +1,4 @@ -import { TaskTableRowConfig } from '../panels/corpglory-dataexporter-panel/types'; +import { ExportTask } from '../panels/corpglory-dataexporter-panel/types'; import axios from 'axios'; import * as _ from 'lodash'; @@ -43,24 +43,24 @@ export const queryApi = async (path: string, config: RequestConfig) => return response.data as RT; }; -export async function getTasks(): Promise { - return queryApi('/task', {}); +export async function getTasks(): Promise { + return queryApi('/task', {}); } -export async function deleteTask(filename?: string): Promise { - if (_.isEmpty(filename)) { - console.warn(`can't delete task without filename`); +export async function deleteTask(taskId?: string): Promise { + if (_.isEmpty(taskId)) { + console.warn(`can't delete task without taskId`); return; } - await queryApi('/task', { method: 'DELETE', data: { filename } }); + await queryApi('/task', { method: 'DELETE', data: { taskId } }); } -export async function getStaticFile(filename?: string): Promise { - if (_.isEmpty(filename)) { - console.warn(`can't download file without name`); +export async function getStaticFile(taskId?: string): Promise { + if (_.isEmpty(taskId)) { + console.warn(`can't download file without taskId`); return; } - const respData = await queryApi(`/static/${filename}.csv`, {}); + const respData = await queryApi(`/static/${taskId}.csv`, {}); // TODO: check if resp exists // create file link in browser's memory const href = URL.createObjectURL(new Blob([respData], { type: 'text/csv' })); @@ -68,7 +68,7 @@ export async function getStaticFile(filename?: string): Promise { // create "a" HTML element with href to file & click const link = document.createElement('a'); link.href = href; - link.setAttribute('download', `${filename}.csv`); + link.setAttribute('download', `${taskId}.csv`); document.body.appendChild(link); link.click(); diff --git a/src/utils/index.ts b/src/utils/index.ts index 43ac013..86e4a09 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { AppEvents } from '@grafana/data'; +import { AppEvents, TimeZone } from '@grafana/data'; // @ts-ignore import appEvents from 'grafana/app/core/app_events'; @@ -15,7 +15,7 @@ export function getDashboardUid(url: string): string { } } -export function convertTimestampToDate(timestamp: number): string { +export function convertTimestampToDate(timestamp?: number): string { const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', @@ -24,5 +24,18 @@ export function convertTimestampToDate(timestamp: number): string { minute: 'numeric', second: 'numeric', }; - return new Date(timestamp).toLocaleString('en-GB', options); + return timestamp ? + new Date(timestamp).toLocaleString('en-GB', options): + '-'; +} + +export function convertTimeZoneTypeToName(timeZone: TimeZone): string { + switch (timeZone) { + case 'utc': + return 'Etc/UTC'; + case 'browser': + return Intl.DateTimeFormat().resolvedOptions().timeZone; + default: + return timeZone; + } }