diff --git a/package.json b/package.json index 067893d..a3e3480 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.4.3", "@types/glob": "^8.0.0", + "@types/grafana": "github:CorpGlory/types-grafana", "@types/jest": "^27.4.1", "@types/lodash-es": "^4.17.6", "@types/node": "^17.0.19", diff --git a/src/icons.ts b/src/icons.ts new file mode 100644 index 0000000..af34bb9 --- /dev/null +++ b/src/icons.ts @@ -0,0 +1,8 @@ +export const CLOSE_ICON_BASE_64 = + 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACJSURBVHgB7ZPRCcAgDESv0lWc0zhC53OIjmAVUpCSamL76YEoyfFIiAGWfldK6SwnKHyhep9xJ3iPcqgH5RyxV1VlBWYJypXVHMEiCaqBbRhAy3W3B76j954wq6ZSVZsOY+WXt6i9l2ymGTlUq0VpOcIqaQC96Zth01DN1zBBefVI4SNp9Za+6wLcH6DKFrfpxgAAAABJRU5ErkJggg=='; +export const DOWNLOAD_ICON_BASE_64 = + 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADlSURBVHgB3ZPNDYJAEIVnN96lBA7A2RIsATuQiiwBSrAD7MAjCZBACXqFwPomMRtEwMVwgZeQhfn5mNnMEG1CaZoeiqKwTWJ3JkFCiEtVVU+8+r9iJRlKSrk3iqOFtWJglmXHf3yDwCRJbBxxnudh34cRYluMMbKGcgWNCIlnjEuIJ1JK2WzDWeKb7YHjONEsYBf6kTABY+mWuYX+3Xiex9UFU7D3FllfwLqueQvi/h8ZCtBprDLY703T6A0yWj2ArmSoxedQV4jSSz5xj4pmCi0/NKfrwNz5bdtaNENciOu6N1qNXhzZXHMb9Q+nAAAAAElFTkSuQmCC'; +export const UNSELECT_ICON_BASE_64 = + 'PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMC41IiB5PSIwLjUiIHdpZHRoPSIxNSIgaGVpZ2h0PSIxNSIgcng9IjEuNSIgZmlsbD0iIzExMTIxNiIgc3Ryb2tlPSIjMkQyRTM0Ii8+Cjwvc3ZnPgo='; +export const SELECT_ICON_BASE_64 = + 'PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiByeD0iMiIgZmlsbD0iIzRBNzFEMiIvPgo8cGF0aCBkPSJNNS45ODI2NCAxMi41MjE3QzUuNzczOTUgMTIuNTIxNyA1LjU4MjY0IDEyLjQ1MjIgNS40MjYxMiAxMi4yOTU2TDEuOTY1MjUgOC44MzQ3NkMxLjY1MjIxIDguNTIxNzIgMS42NTIyMSA4LjAzNDc2IDEuOTY1MjUgNy43MjE3MkMyLjI3ODI5IDcuNDA4NjcgMi43NjUyNSA3LjQwODY3IDMuMDc4MjkgNy43MjE3Mkw2LjAwMDAzIDEwLjYyNjFMMTIuOTM5MiAzLjcwNDMzQzEzLjI1MjIgMy4zOTEyOCAxMy43MzkyIDMuMzkxMjggMTQuMDUyMiAzLjcwNDMzQzE0LjM2NTMgNC4wMTczNyAxNC4zNjUzIDQuNTA0MzMgMTQuMDUyMiA0LjgxNzM3TDYuNTU2NTYgMTIuMjk1NkM2LjM4MjY0IDEyLjQ1MjIgNi4xOTEzNCAxMi41MjE3IDUuOTgyNjQgMTIuNTIxN1YxMi41MjE3WiIgZmlsbD0iI0ZFRkZGRiIvPgo8L3N2Zz4K'; diff --git a/src/panels/corpglory-dataexporter-panel/components/Panel.tsx b/src/panels/corpglory-dataexporter-panel/components/Panel.tsx index 361132c..9c7494a 100644 --- a/src/panels/corpglory-dataexporter-panel/components/Panel.tsx +++ b/src/panels/corpglory-dataexporter-panel/components/Panel.tsx @@ -1,7 +1,12 @@ -import { PanelOptions, TaskTableRowConfig, DatasourceTableRowConfig } from '../types'; +import { PanelOptions, TaskTableRowConfig, QueryTableRowConfig, DatasourceType } from '../types'; -import { getBackendSrv } from '@grafana/runtime'; -import { makeRequest } from 'services/network_service'; +import { convertTimestampToDate, 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'; +import { getDashboardByUid, getDatasources } from '../../../services/grafana_backend_service'; + +import { contextSrv } from 'grafana/app/core/core'; import { Table, Button, HorizontalGroup, Modal, LoadingPlaceholder } from '@grafana/ui'; import { @@ -12,91 +17,159 @@ import { createTheme, DataFrame, DataLinkClickEvent, + PanelModel, + DataQuery, + DataSourceSettings, } from '@grafana/data'; +import { RefreshEvent } from '@grafana/runtime'; import React, { useState, useEffect } from 'react'; import * as _ from 'lodash'; interface Props extends PanelProps {} -export function Panel({ options, data, width, height, timeRange, onChangeTimeRange }: Props) { +export function Panel({ width, height, timeRange, eventBus }: 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 [tasksDataFrame, setTasksDataFrame] = useState(null); + const [queriesDataFrame, setQueriesDataFrame] = useState(null); - useEffect(() => { - if (tasks === null) { - return; - } - const dataFrame = getDataFrameForTaskTable(tasks, setTasks); - setTasksDataFrame(dataFrame); - }, [tasks]); + const [isModalOpen, setModalVisibility] = useState(false); useEffect(() => { - // TODO: move this function to Network Service - async function getTasks(): Promise { - return makeRequest('/tasks', {}); + async function getCurrentDashboard(): Promise { + const currentDashboardUid = getDashboardUid(window.location.toString()); + + return getDashboardByUid(currentDashboardUid); } - getTasks() - .then((tasks) => setTasks(tasks)) + getCurrentDashboard() + .then((dash) => setDashboard(dash.dashboard)) .catch((err) => console.error(err)); }, []); - const datasourceConfigs: DatasourceTableRowConfig[] = []; - const [datasources, setDatasources] = useState(datasourceConfigs); - const datasourceDataFrame = getDataFrameForDatasourceTable(datasources, setDatasources); + useEffect(() => { + getDatasources() + .then((datasources) => setDatasources(datasources)) + .catch((err) => console.error(err)); + }, []); - const [isModalOpen, setModalVisibility] = useState(false); + useEffect(() => { + if (!dashboard || !datasources) { + return; + } + dashboard.panels.forEach((panel: PanelModel) => { + const queries: QueryTableRowConfig[] = []; - const timestampRange: [number, number] = [timeRange.from.unix(), timeRange.to.unix()]; - const backendSrv = getBackendSrv(); - // @ts-ignore - backendSrv.getInspectorStream().subscribe({ - next: (resp: any) => { - const queries = resp?.config?.data?.queries; - if (_.isEmpty(queries)) { + // @ts-ignore + // TODO: move plugin id to const + if (panel.type === 'corpglory-dataexporter-panel') { return; } - const datasource = queries[0].datasource.type; - const refId = queries[0].refId; - const rawSql = queries[0].rawSql; - const uid = queries[0].datasource.uid; - // TODO: it works only with sql (rawSql will be empty in prometheus) - const isDatasourceExist = _.some( - datasources, - (ds: DatasourceTableRowConfig) => - ds.datasource === datasource && ds.refId === refId && _.isEqual(ds.rawSql, rawSql) && ds.uid === uid - ); - if (isDatasourceExist) { + if (!_.includes(_.values(DatasourceType), panel.datasource?.type)) { return; } - const newConfig = createDatasourceConfig({ datasource, refId, rawSql, uid }, timestampRange); - setDatasources([...datasources, newConfig]); - }, - }); - - function onAddTaskClick(): void { - const selectedDatasources = _.filter(datasources, (datasource: DatasourceTableRowConfig) => datasource.select); - const newTasks = _.map(selectedDatasources, (datasource: DatasourceTableRowConfig) => - createTaskFromDatasource(datasource) - ); - if (_.isEmpty(tasks)) { - setTasks([...newTasks]); - } else { - setTasks([...(tasks as TaskTableRowConfig[]), ...newTasks]); + + 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 }); + }); + + setQueries(queries); + }); + }, [dashboard, datasources]); // eslint-disable-line react-hooks/exhaustive-deps + + 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 + + useEffect(() => { + if (queries === null) { + return; + } + setQueriesDataFrame(getDataFrameForQueriesTable(queries)); + }, [queries]); // eslint-disable-line react-hooks/exhaustive-deps + + function refresh(): void { + getTasks() + .then((tasks) => { + setTasks(tasks); + for (let task of tasks) { + // TODO: ExportStatus enum + if (task.status === 'exporting') { + setTimeout(refresh, 1000); + return; + } + } + }) + .catch((err) => console.error(err)); + } + + eventBus.subscribe(RefreshEvent, refresh); + + 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(): Promise { + const selectedQueries = _.filter(queries, (query: QueryTableRowConfig) => query.selected); + + // TODO: timerange picker + const timerange: [number, number] = [timeRange.from.unix(), timeRange.to.unix()]; + // 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, + url: window.location.toString(), + }, + }); + + refresh(); onCloseModal(); - unselectAllDatasources(); + unselectAllQueries(); } - function unselectAllDatasources(): void { - const updatedDatasources = _.clone(datasources); - for (const ds of updatedDatasources) { - ds.select = false; + function unselectAllQueries(): void { + if (queries === null) { + return; } - setDatasources(updatedDatasources); + setQueries(queries.map((query: QueryTableRowConfig) => ({ ...query, selected: false }))); } function openDatasourceModal(): void { @@ -105,7 +178,176 @@ export function Panel({ options, data, width, height, timeRange, onChangeTimeRan function onCloseModal(): void { setModalVisibility(false); - unselectAllDatasources(); + unselectAllQueries(); + } + + function getDataFrameForQueriesTable(configs: QueryTableRowConfig[]): DataFrame { + const dataFrame = toDataFrame({ + name: 'A', + fields: [ + { + name: 'Select', + type: FieldType.string, + values: _.map( + configs, + (config) => `data:image/svg+xml;base64,${config.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(configs, (config) => config.panel.title), + }, + { + name: 'RefId', + type: FieldType.string, + values: _.map(configs, (config) => config.refId), + }, + { + name: 'Datasource', + type: FieldType.string, + values: _.map(configs, (config) => config.datasource.name), + }, + ], + }); + + const dataFrames = applyFieldOverrides({ + data: [dataFrame], + fieldConfig: { + overrides: [], + defaults: {}, + }, + theme: createTheme(), + replaceVariables: (value: string) => value, + }); + return dataFrames[0]; + } + + function getDataFrameForTaskTable(configs: TaskTableRowConfig[]): DataFrame { + const dataFrame = toDataFrame({ + name: 'A', + fields: [ + { + name: 'Time', + type: FieldType.number, + values: _.map(configs, (config) => convertTimestampToDate(config.timestamp)), + }, + { + name: 'User', + type: FieldType.string, + values: _.map(configs, (config) => config.username), + }, + { + name: 'Datasource', + type: FieldType.string, + values: _.map(configs, (config) => getDatasourceByUid(config.datasourceRef?.uid)?.name), + }, + { + name: 'Exported Rows', + type: FieldType.number, + values: _.map(configs, (config) => config.rowsCount), + }, + { + name: 'Progress', + type: FieldType.string, + values: _.map(configs, (config) => `${(config.progress * 100).toFixed(0)}%`), + }, + { + name: 'Status', + type: FieldType.string, + values: _.map(configs, (config) => config.status), + }, + { + name: 'Download CSV', + type: FieldType.string, + values: _.map(configs, () => `data:image/png;base64,${DOWNLOAD_ICON_BASE_64}`), + config: { + custom: { + filterable: false, + displayMode: 'image', + }, + links: [ + { + targetBlank: false, + title: 'Download', + url: '#', + onClick: (event: DataLinkClickEvent) => onDownloadClick(event), + }, + ], + }, + }, + { + name: 'Delete task', + type: FieldType.string, + values: _.map(configs, () => `data:image/png;base64,${CLOSE_ICON_BASE_64}`), + config: { + custom: { + filterable: false, + displayMode: '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(e: DataLinkClickEvent): Promise { + const rowIndex = e.origin.rowIndex; + + const task = _.find(tasks, (task, idx) => idx === rowIndex); + await deleteTask(task?.filename); + + const filteredTasks = _.filter(tasks, (task, idx) => idx !== rowIndex); + setTasks(filteredTasks); + } + + function onDownloadClick(e: DataLinkClickEvent): void { + const rowIndex = e.origin.rowIndex; + const task = _.find(tasks, (task, idx) => idx === rowIndex); + getStaticFile(task?.filename); + } + + 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); } return ( @@ -126,18 +368,27 @@ export function Panel({ options, data, width, height, timeRange, onChangeTimeRan > Add Task - - - - - + + {queriesDataFrame === null ? ( + // TODO: if datasource responds with error, display the error + + ) : ( +
+
+ + + + + )} @@ -145,206 +396,3 @@ export function Panel({ options, data, width, height, timeRange, onChangeTimeRan ); } - -function getDataFrameForTaskTable(configs: TaskTableRowConfig[], setTasks: any): DataFrame { - const dataFrame = toDataFrame({ - name: 'A', - fields: [ - { - name: 'Time', - type: FieldType.number, - values: _.map(configs, (config) => convertTimestampToDate(config.timestamp)), - }, - { - name: 'User', - type: FieldType.string, - values: _.map(configs, (config) => config.user), - }, - { - name: 'Datasource', - type: FieldType.string, - values: _.map(configs, (config) => config.datasource), - }, - { - name: 'Exported Rows', - type: FieldType.number, - values: _.map(configs, (config) => config.rowsCount), - }, - { - name: 'Progress', - type: FieldType.string, - values: _.map(configs, (config) => `${config.progress}%`), - }, - { - name: 'Status', - type: FieldType.string, - values: _.map(configs, (config) => config.status), - }, - { - name: 'Download CSV', - type: FieldType.string, - values: _.map(configs, (config) => `data:image/png;base64,${DOWNLOAD_ICON_BASE_64}`), - config: { - custom: { - filterable: false, - displayMode: 'image', - }, - links: [ - { - targetBlank: false, - title: 'CSV link', - url: 'https://chartwerk.io/', - }, - ], - }, - }, - { - name: 'Delete task', - type: FieldType.string, - values: _.map(configs, (config) => `data:image/png;base64,${CLOSE_ICON_BASE_64}`), - config: { - custom: { - filterable: false, - displayMode: 'image', - }, - links: [ - { - targetBlank: false, - title: 'Delete', - url: '#', - onClick: (event: DataLinkClickEvent) => onDeleteClick(event, configs, setTasks), - }, - ], - }, - }, - ], - }); - - const dataFrames = applyFieldOverrides({ - data: [dataFrame], - fieldConfig: { - overrides: [], - defaults: {}, - }, - theme: createTheme(), - replaceVariables: (value: string) => value, - }); - return dataFrames[0]; -} - -function getDataFrameForDatasourceTable(configs: DatasourceTableRowConfig[], setDatasources: any): DataFrame { - const dataFrame = toDataFrame({ - name: 'A', - fields: [ - { - name: 'Select', - type: FieldType.string, - values: _.map( - configs, - (config) => `data:image/svg+xml;base64,${config.select ? 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, configs, setDatasources), - }, - ], - }, - }, - { - name: 'Panel', - type: FieldType.string, - values: _.map(configs, (config) => config.panel), - }, - { - name: 'RefId', - type: FieldType.string, - values: _.map(configs, (config) => config.refId), - }, - { - name: 'Datasource', - type: FieldType.string, - values: _.map(configs, (config) => config.datasource), - }, - ], - }); - - const dataFrames = applyFieldOverrides({ - data: [dataFrame], - fieldConfig: { - overrides: [], - defaults: {}, - }, - theme: createTheme(), - replaceVariables: (value: string) => value, - }); - return dataFrames[0]; -} - -function onDeleteClick(e: DataLinkClickEvent, tasks: TaskTableRowConfig[], setTasks: any): void { - const rowIndex = e.origin.rowIndex; - const filteredTasks = _.filter(tasks, (task, idx) => idx !== rowIndex); - setTasks(filteredTasks); -} - -function onDatasourceSelectClick( - e: DataLinkClickEvent, - datasources: DatasourceTableRowConfig[], - setDatasources: any -): void { - const rowIndex = e.origin.rowIndex; - const updatedDatasources = _.clone(datasources); - updatedDatasources[rowIndex].select = !updatedDatasources[rowIndex].select; - setDatasources(updatedDatasources); -} - -function createTaskFromDatasource(config: DatasourceTableRowConfig): TaskTableRowConfig { - return { - timestamp: Date.now(), - user: 'admin', - datasource: config.datasource, - rowsCount: 3214, - progress: 100, - status: 'finished', - }; -} - -function createDatasourceConfig(obj: any, timerange: [number, number]): DatasourceTableRowConfig { - return { - select: false, - panel: 'Panel', - refId: obj.refId, - datasource: obj.datasource, - rawSql: obj.rawSql, - uid: obj.uid, - timerange, - }; -} - -function convertTimestampToDate(timestamp: number): string { - const options: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - }; - return new Date(timestamp).toLocaleString('en-GB', options); -} - -const CLOSE_ICON_BASE_64 = - 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACJSURBVHgB7ZPRCcAgDESv0lWc0zhC53OIjmAVUpCSamL76YEoyfFIiAGWfldK6SwnKHyhep9xJ3iPcqgH5RyxV1VlBWYJypXVHMEiCaqBbRhAy3W3B76j954wq6ZSVZsOY+WXt6i9l2ymGTlUq0VpOcIqaQC96Zth01DN1zBBefVI4SNp9Za+6wLcH6DKFrfpxgAAAABJRU5ErkJggg=='; -const DOWNLOAD_ICON_BASE_64 = - 'iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADlSURBVHgB3ZPNDYJAEIVnN96lBA7A2RIsATuQiiwBSrAD7MAjCZBACXqFwPomMRtEwMVwgZeQhfn5mNnMEG1CaZoeiqKwTWJ3JkFCiEtVVU+8+r9iJRlKSrk3iqOFtWJglmXHf3yDwCRJbBxxnudh34cRYluMMbKGcgWNCIlnjEuIJ1JK2WzDWeKb7YHjONEsYBf6kTABY+mWuYX+3Xiex9UFU7D3FllfwLqueQvi/h8ZCtBprDLY703T6A0yWj2ArmSoxedQV4jSSz5xj4pmCi0/NKfrwNz5bdtaNENciOu6N1qNXhzZXHMb9Q+nAAAAAElFTkSuQmCC'; -const UNSELECT_ICON_BASE_64 = - 'PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMC41IiB5PSIwLjUiIHdpZHRoPSIxNSIgaGVpZ2h0PSIxNSIgcng9IjEuNSIgZmlsbD0iIzExMTIxNiIgc3Ryb2tlPSIjMkQyRTM0Ii8+Cjwvc3ZnPgo='; -const SELECT_ICON_BASE_64 = - 'PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiByeD0iMiIgZmlsbD0iIzRBNzFEMiIvPgo8cGF0aCBkPSJNNS45ODI2NCAxMi41MjE3QzUuNzczOTUgMTIuNTIxNyA1LjU4MjY0IDEyLjQ1MjIgNS40MjYxMiAxMi4yOTU2TDEuOTY1MjUgOC44MzQ3NkMxLjY1MjIxIDguNTIxNzIgMS42NTIyMSA4LjAzNDc2IDEuOTY1MjUgNy43MjE3MkMyLjI3ODI5IDcuNDA4NjcgMi43NjUyNSA3LjQwODY3IDMuMDc4MjkgNy43MjE3Mkw2LjAwMDAzIDEwLjYyNjFMMTIuOTM5MiAzLjcwNDMzQzEzLjI1MjIgMy4zOTEyOCAxMy43MzkyIDMuMzkxMjggMTQuMDUyMiAzLjcwNDMzQzE0LjM2NTMgNC4wMTczNyAxNC4zNjUzIDQuNTA0MzMgMTQuMDUyMiA0LjgxNzM3TDYuNTU2NTYgMTIuMjk1NkM2LjM4MjY0IDEyLjQ1MjIgNi4xOTEzNCAxMi41MjE3IDUuOTgyNjQgMTIuNTIxN1YxMi41MjE3WiIgZmlsbD0iI0ZFRkZGRiIvPgo8L3N2Zz4K'; diff --git a/src/panels/corpglory-dataexporter-panel/types.ts b/src/panels/corpglory-dataexporter-panel/types.ts index 9b2a525..c83ef92 100644 --- a/src/panels/corpglory-dataexporter-panel/types.ts +++ b/src/panels/corpglory-dataexporter-panel/types.ts @@ -1,20 +1,28 @@ +import { DataQuery, DataSourceRef, DataSourceSettings, PanelModel } from '@grafana/data'; + export interface PanelOptions {} export type TaskTableRowConfig = { timestamp: number; - user: string; - datasource: string; + username: string; + datasourceRef: DataSourceRef; rowsCount: number; progress: number; status: string; + filename?: string; }; -export type DatasourceTableRowConfig = { - select: boolean; - panel: string; - refId: string; - datasource: string; - rawSql: string; - uid: string; - timerange: [number, number]; +export type QueryTableRowConfig = Omit & { + selected: boolean; + panel: PanelModel; + datasource: DataSourceSettings; }; + +export enum DatasourceType { + INFLUXDB = 'influxdb', + GRAPHITE = 'graphite', + PROMETHEUS = 'prometheus', + POSTGRES = 'postgres', + ELASTICSEARCH = 'elasticsearch', + MYSQL = 'mysql', +} diff --git a/src/plugin_state.ts b/src/plugin_state.ts index 1c65bbf..0812be3 100644 --- a/src/plugin_state.ts +++ b/src/plugin_state.ts @@ -1,4 +1,4 @@ -import { makeRequest } from './services/network_service'; +import { queryApi } from './services/api_service'; import { DataExporterAppPluginMeta, DataExporterPluginMetaJSONData, @@ -114,7 +114,7 @@ class PluginState { // TODO: try to disable success alerts from Grafana API const { key: grafanaToken } = await this.createGrafanaToken(); await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } }); - const dataExporterAPIResponse = await makeRequest(`/connect`, { + const dataExporterAPIResponse = await queryApi(`/connect`, { method: 'POST', }); return { grafanaToken, dataExporterAPIResponse }; @@ -165,7 +165,7 @@ class PluginState { dataExporterApiUrl: string ): Promise => { try { - const resp = await makeRequest(`/status`, { + const resp = await queryApi(`/status`, { method: 'GET', }); diff --git a/src/services/api_service.ts b/src/services/api_service.ts new file mode 100644 index 0000000..a803327 --- /dev/null +++ b/src/services/api_service.ts @@ -0,0 +1,78 @@ +import { TaskTableRowConfig } from '../panels/corpglory-dataexporter-panel/types'; + +import axios from 'axios'; +import * as _ from 'lodash'; + +export const API_HOST = `${window.location.protocol}//${window.location.host}/`; +export const API_PROXY_PREFIX = 'api/plugin-proxy/corpglory-dataexporter-app'; +export const API_PATH_PREFIX = '/api'; + +const instance = axios.create(); + +instance.interceptors.request.use(function (config) { + config.validateStatus = (status) => { + return status >= 200 && status < 300; // default + }; + + return { + ...config, + }; +}); + +interface RequestConfig { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS'; + params?: any; + data?: any; + withCredentials?: boolean; + validateStatus?: (status: number) => boolean; +} + +export const queryApi = async (path: string, config: RequestConfig) => { + const { method = 'GET', params, data, validateStatus } = config; + + const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`; + + const response = await instance({ + method, + url, + params, + data, + validateStatus, + }); + + return response.data as RT; +}; + +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`); + return; + } + await queryApi('/task', { method: 'DELETE', data: { filename } }); +} + +export async function getStaticFile(filename?: string): Promise { + if (_.isEmpty(filename)) { + console.warn(`can't download file without name`); + return; + } + const respData = await queryApi(`/static/${filename}.csv`, {}); + // TODO: check if resp exists + // create file link in browser's memory + const href = URL.createObjectURL(new Blob([respData], { type: 'text/csv' })); + + // create "a" HTML element with href to file & click + const link = document.createElement('a'); + link.href = href; + link.setAttribute('download', `${filename}.csv`); + document.body.appendChild(link); + link.click(); + + // clean up "a" element & remove ObjectURL + document.body.removeChild(link); + URL.revokeObjectURL(href); +} diff --git a/src/services/grafana_backend_service.ts b/src/services/grafana_backend_service.ts new file mode 100644 index 0000000..565c9f0 --- /dev/null +++ b/src/services/grafana_backend_service.ts @@ -0,0 +1,11 @@ +import { getBackendSrv } from '@grafana/runtime'; + +export async function getDatasources() { + const backendSrv = getBackendSrv(); + return backendSrv.get(`/api/datasources`); +} + +export async function getDashboardByUid(uid: string) { + const backendSrv = getBackendSrv(); + return backendSrv.get(`/api/dashboards/uid/${uid}`); +} diff --git a/src/services/network_service.ts b/src/services/network_service.ts deleted file mode 100644 index 577860e..0000000 --- a/src/services/network_service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import axios from 'axios'; - -export const API_HOST = `${window.location.protocol}//${window.location.host}/`; -export const API_PROXY_PREFIX = 'api/plugin-proxy/corpglory-dataexporter-app'; -export const API_PATH_PREFIX = '/api'; - -const instance = axios.create(); - -instance.interceptors.request.use(function (config) { - config.validateStatus = (status) => { - return status >= 200 && status < 300; // default - }; - - return { - ...config, - }; -}); - -interface RequestConfig { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS'; - params?: any; - data?: any; - withCredentials?: boolean; - validateStatus?: (status: number) => boolean; -} - -export const makeRequest = async (path: string, config: RequestConfig) => { - const { method = 'GET', params, data, validateStatus } = config; - - const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`; - - const response = await instance({ - method, - url, - params, - data, - validateStatus, - }); - - return response.data as RT; -}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 32d5f31..43ac013 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,3 +5,24 @@ import appEvents from 'grafana/app/core/app_events'; export function openNotification(message: React.ReactNode) { appEvents.emit(AppEvents.alertSuccess, [message]); } + +export function getDashboardUid(url: string): string { + const matches = new URL(url).pathname.match(/\/d\/([^/]+)/); + if (!matches) { + throw new Error(`Couldn't parse uid from ${url}`); + } else { + return matches[1]; + } +} + +export function convertTimestampToDate(timestamp: number): string { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }; + return new Date(timestamp).toLocaleString('en-GB', options); +} diff --git a/yarn.lock b/yarn.lock index 45dfbae..5dfa76b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3592,6 +3592,10 @@ dependencies: "@types/node" "*" +"@types/grafana@github:CorpGlory/types-grafana": + version "4.6.3" + resolved "https://codeload.github.com/CorpGlory/types-grafana/tar.gz/4beede5fa0e5bdebdb23b09e4e1e214dcc4c267f" + "@types/history@^4.7.11": version "4.7.11" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64"