Browse Source

Merge branch 'master' into dist

dist
rozetko 1 year ago
parent
commit
005c7009c3
  1. 237
      src/panels/corpglory-dataexporter-panel/components/Panel.tsx
  2. 57
      src/panels/corpglory-dataexporter-panel/types.ts
  3. 24
      src/services/api_service.ts
  4. 19
      src/utils/index.ts

237
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 { 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 { deleteTask, getStaticFile, getTasks, queryApi } from '../../../services/api_service';
@ -31,21 +31,25 @@ import {
DataQuery, DataQuery,
DataSourceSettings, DataSourceSettings,
TimeRange, TimeRange,
OrgRole,
} from '@grafana/data'; } from '@grafana/data';
import { RefreshEvent } from '@grafana/runtime'; import { RefreshEvent } from '@grafana/runtime';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import * as _ from 'lodash'; import * as _ from 'lodash';
const PANEL_ID = 'corpglory-dataexporter-panel';
const APP_ID = 'corpglory-dataexporter-app';
interface Props extends PanelProps<PanelOptions> {} interface Props extends PanelProps<PanelOptions> {}
export function Panel({ width, height, timeRange, eventBus }: Props) { export function Panel({ width, height, timeRange, eventBus, timeZone }: Props) {
// TODO: Dashboard type // TODO: Dashboard type
const [dashboard, setDashboard] = useState<any | 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<TaskTableRowConfig[] | null>(null); const [tasks, setTasks] = useState<ExportTask[] | null>(null);
const [queries, setQueries] = useState<QueryTableRowConfig[] | null>(null); const [queries, setQueries] = useState<DashboardQuery[] | null>(null);
const [tasksDataFrame, setTasksDataFrame] = useState<DataFrame | null>(null); const [tasksDataFrame, setTasksDataFrame] = useState<DataFrame | null>(null);
const [queriesDataFrame, setQueriesDataFrame] = useState<DataFrame | null>(null); const [queriesDataFrame, setQueriesDataFrame] = useState<DataFrame | null>(null);
@ -54,6 +58,15 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
const [selectedTimeRange, setTimeRange] = useState<TimeRange>(timeRange); const [selectedTimeRange, setTimeRange] = useState<TimeRange>(timeRange);
const [panelStatus, setPanelStatus] = useState<PanelStatus>(PanelStatus.LOADING);
const timeZoneName = convertTimeZoneTypeToName(timeZone);
if (contextSrv.user.orgRole !== OrgRole.Admin) {
// TODO: it shouldn't be overriten
setPanelStatus(PanelStatus.PERMISSION_ERROR);
}
useEffect(() => { useEffect(() => {
async function getCurrentDashboard(): Promise<any> { async function getCurrentDashboard(): Promise<any> {
const currentDashboardUid = getDashboardUid(window.location.toString()); const currentDashboardUid = getDashboardUid(window.location.toString());
@ -77,11 +90,10 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
return; return;
} }
dashboard.panels.forEach((panel: PanelModel) => { dashboard.panels.forEach((panel: PanelModel) => {
const queries: QueryTableRowConfig[] = []; const queries: DashboardQuery[] = [];
// @ts-ignore // @ts-ignore
// TODO: move plugin id to const if (panel.type === PANEL_ID) {
if (panel.type === 'corpglory-dataexporter-panel') {
return; return;
} }
@ -90,12 +102,11 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
} }
panel.targets?.forEach((target: DataQuery) => { panel.targets?.forEach((target: DataQuery) => {
console.log('uid', target.datasource?.uid);
const datasource = getDatasourceByUid(target.datasource?.uid); const datasource = getDatasourceByUid(target.datasource?.uid);
if (!datasource) { if (!datasource) {
return; return;
} }
queries.push({ ...target, selected: false, panel, datasource }); queries.push({ selected: false, target, panel, datasource });
}); });
setQueries(queries); setQueries(queries);
@ -123,15 +134,18 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
getTasks() getTasks()
.then((tasks) => { .then((tasks) => {
setTasks(tasks); setTasks(tasks);
setPanelStatus(PanelStatus.OK);
for (let task of tasks) { for (let task of tasks) {
// TODO: ExportStatus enum if (task.progress?.status === ExportStatus.EXPORTING) {
if (task.status === 'exporting') {
setTimeout(refresh, 1000); setTimeout(refresh, 1000);
return; return;
} }
} }
}) })
.catch((err) => console.error(err)); .catch((err) => {
setPanelStatus(PanelStatus.DATASOURCE_ERROR);
console.error('some error', err);
});
} }
eventBus.subscribe(RefreshEvent, refresh); eventBus.subscribe(RefreshEvent, refresh);
@ -154,20 +168,26 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
return datasource; return datasource;
} }
async function onAddTaskClick(): Promise<void> { async function onAddTaskClick(timeZoneName: string): Promise<void> {
const selectedQueries = _.filter(queries, (query: QueryTableRowConfig) => query.selected); const selectedQueries = _.filter(queries, (query: DashboardQuery) => query.selected);
// TODO: timerange picker
const timerange: [number, number] = [selectedTimeRange.from.unix(), selectedTimeRange.to.unix()]; 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 // TODO: move this function to API Service
await queryApi('/task', { await queryApi('/task', {
method: 'POST', method: 'POST',
data: { data: {
from: timerange[0] * 1000, task,
to: timerange[1] * 1000,
// @ts-ignore
username: contextSrv.user.name,
tasks: selectedQueries,
url: window.location.toString(), url: window.location.toString(),
timeZoneName,
}, },
}); });
@ -181,10 +201,11 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
if (queries === null) { if (queries === null) {
return; return;
} }
setQueries(queries.map((query: QueryTableRowConfig) => ({ ...query, selected: false }))); setQueries(queries.map((query: DashboardQuery) => ({ ...query, selected: false })));
} }
function openDatasourceModal(): void { function openDatasourceModal(): void {
setTimeRange(timeRange);
setModalVisibility(true); setModalVisibility(true);
} }
@ -193,7 +214,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
unselectAllQueries(); unselectAllQueries();
} }
function getDataFrameForQueriesTable(configs: QueryTableRowConfig[]): DataFrame { function getDataFrameForQueriesTable(queries: DashboardQuery[]): DataFrame {
const dataFrame = toDataFrame({ const dataFrame = toDataFrame({
name: 'A', name: 'A',
fields: [ fields: [
@ -201,8 +222,8 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
name: 'Select', name: 'Select',
type: FieldType.string, type: FieldType.string,
values: _.map( values: _.map(
configs, queries,
(config) => `data:image/svg+xml;base64,${config.selected ? SELECT_ICON_BASE_64 : UNSELECT_ICON_BASE_64}` (query) => `data:image/svg+xml;base64,${query.selected ? SELECT_ICON_BASE_64 : UNSELECT_ICON_BASE_64}`
), ),
config: { config: {
custom: { custom: {
@ -222,17 +243,17 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
{ {
name: 'Panel', name: 'Panel',
type: FieldType.string, type: FieldType.string,
values: _.map(configs, (config) => config.panel.title), values: _.map(queries, (query) => query.panel.title),
}, },
{ {
name: 'RefId', name: 'RefId',
type: FieldType.string, type: FieldType.string,
values: _.map(configs, (config) => config.refId), values: _.map(queries, (query) => query.target.refId),
}, },
{ {
name: 'Datasource', name: 'Datasource',
type: FieldType.string, 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]; return dataFrames[0];
} }
function getDataFrameForTaskTable(configs: TaskTableRowConfig[]): DataFrame { function getDataFrameForTaskTable(tasks: ExportTask[]): DataFrame {
const dataFrame = toDataFrame({ const dataFrame = toDataFrame({
name: 'A', name: 'A',
fields: [ fields: [
{ {
name: 'Time', name: 'Time',
type: FieldType.number, type: FieldType.number,
values: _.map(configs, (config) => convertTimestampToDate(config.timestamp)), values: _.map(tasks, (task) => convertTimestampToDate(task.progress?.time)),
}, },
{ {
name: 'User', name: 'User',
type: FieldType.string, type: FieldType.string,
values: _.map(configs, (config) => config.username), values: _.map(tasks, (task) => task.username),
}, },
{ {
name: 'Datasource', name: 'Datasource',
type: FieldType.string, 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', name: 'Exported Rows',
type: FieldType.number, type: FieldType.number,
values: _.map(configs, (config) => config.rowsCount), values: _.map(tasks, (task) => task.progress?.exportedRowsCount),
}, },
{ {
name: 'Progress', name: 'Progress',
type: FieldType.string, 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', name: 'Status',
type: FieldType.string, 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', name: 'Download CSV',
type: FieldType.string, 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: { config: {
custom: { custom: {
filterable: false, filterable: false,
@ -305,7 +331,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
{ {
name: 'Delete task', name: 'Delete task',
type: FieldType.string, 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: { config: {
custom: { custom: {
filterable: false, filterable: false,
@ -340,7 +366,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
const rowIndex = e.origin.rowIndex; const rowIndex = e.origin.rowIndex;
const task = _.find(tasks, (task, idx) => idx === 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); const filteredTasks = _.filter(tasks, (task, idx) => idx !== rowIndex);
setTasks(filteredTasks); setTasks(filteredTasks);
@ -349,7 +375,7 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
function onDownloadClick(e: DataLinkClickEvent): void { function onDownloadClick(e: DataLinkClickEvent): void {
const rowIndex = e.origin.rowIndex; const rowIndex = e.origin.rowIndex;
const task = _.find(tasks, (task, idx) => idx === rowIndex); const task = _.find(tasks, (task, idx) => idx === rowIndex);
getStaticFile(task?.filename); getStaticFile(task?.id);
} }
function onDatasourceSelectClick(e: DataLinkClickEvent): void { function onDatasourceSelectClick(e: DataLinkClickEvent): void {
@ -364,65 +390,86 @@ export function Panel({ width, height, timeRange, eventBus }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( const loadingDiv = <LoadingPlaceholder text="Loading..."></LoadingPlaceholder>;
// TODO: add styles
const datasourceErrorDiv = (
<div>
<p>Datasource is unavailable.</p>
<div>
Click <a href={`/plugins/${APP_ID}`}>here</a> to configure DataExporter.
</div>
{/* TODO: display error message? */}
</div>
);
const permissionErrorDiv = (
<div> <div>
{tasksDataFrame === null ? ( <p>Permission Error.</p>
// TODO: if datasource responds with error, display the error <div> DataExporter panel availabel only for Admins </div>
<LoadingPlaceholder text="Loading..."></LoadingPlaceholder>
) : (
<div>
<Table width={width} height={height - 40} data={tasksDataFrame} />
<HorizontalGroup justify="flex-end">
<Button
variant="primary"
aria-label="Rich history button"
icon="plus"
style={{ marginTop: '8px' }}
onClick={openDatasourceModal}
>
Add Task
</Button>
<Modal
title="Select Queries"
isOpen={isModalOpen}
onDismiss={onCloseModal}
className={styles.calendarModal}
>
{queriesDataFrame === null ? (
// TODO: if datasource responds with error, display the error
<LoadingPlaceholder text="Loading..."></LoadingPlaceholder>
) : (
<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}
// TODO: move to function
disabled={!queries?.filter((query: QueryTableRowConfig) => query.selected)?.length}
>
Add Task
</Button>
</HorizontalGroup>
</VerticalGroup>
</div>
)}
</Modal>
</HorizontalGroup>
</div>
)}
</div> </div>
); );
const mainDiv = (
<div>
<Table width={width} height={height - 40} data={tasksDataFrame as DataFrame} />
<HorizontalGroup justify="flex-end">
<Button
variant="primary"
aria-label="Rich history button"
icon="plus"
style={{ marginTop: '8px' }}
onClick={openDatasourceModal}
>
Add Task
</Button>
<Modal title="Select Queries" isOpen={isModalOpen} onDismiss={onCloseModal} className={styles.calendarModal}>
{queriesDataFrame === null ? (
// TODO: if datasource responds with error, display the error
<LoadingPlaceholder text="Loading..."></LoadingPlaceholder>
) : (
<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 = () => ({ const getStyles = () => ({

57
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 interface PanelOptions {}
export type TaskTableRowConfig = {
timestamp: number;
username: string;
datasourceRef: DataSourceRef;
rowsCount: number;
progress: number;
status: string;
filename?: string;
};
export type QueryTableRowConfig = Omit<DataQuery, 'datasource'> & {
selected: boolean;
panel: PanelModel;
datasource: DataSourceSettings;
};
export enum DatasourceType { export enum DatasourceType {
INFLUXDB = 'influxdb', INFLUXDB = 'influxdb',
GRAPHITE = 'graphite', GRAPHITE = 'graphite',
@ -26,3 +10,42 @@ export enum DatasourceType {
ELASTICSEARCH = 'elasticsearch', ELASTICSEARCH = 'elasticsearch',
MYSQL = 'mysql', 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;
};

24
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 axios from 'axios';
import * as _ from 'lodash'; import * as _ from 'lodash';
@ -43,24 +43,24 @@ 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<TaskTableRowConfig[]> { export async function getTasks(): Promise<ExportTask[]> {
return queryApi<TaskTableRowConfig[]>('/task', {}); return queryApi<ExportTask[]>('/task', {});
} }
export async function deleteTask(filename?: string): Promise<void> { export async function deleteTask(taskId?: string): Promise<void> {
if (_.isEmpty(filename)) { if (_.isEmpty(taskId)) {
console.warn(`can't delete task without filename`); console.warn(`can't delete task without taskId`);
return; return;
} }
await queryApi<TaskTableRowConfig[]>('/task', { method: 'DELETE', data: { filename } }); await queryApi<ExportTask[]>('/task', { method: 'DELETE', data: { taskId } });
} }
export async function getStaticFile(filename?: string): Promise<void> { export async function getStaticFile(taskId?: string): Promise<void> {
if (_.isEmpty(filename)) { if (_.isEmpty(taskId)) {
console.warn(`can't download file without name`); console.warn(`can't download file without taskId`);
return; return;
} }
const respData = await queryApi(`/static/${filename}.csv`, {}); const respData = await queryApi(`/static/${taskId}.csv`, {});
// TODO: check if resp exists // TODO: check if resp exists
// create file link in browser's memory // create file link in browser's memory
const href = URL.createObjectURL(new Blob([respData], { type: 'text/csv' })); const href = URL.createObjectURL(new Blob([respData], { type: 'text/csv' }));
@ -68,7 +68,7 @@ export async function getStaticFile(filename?: string): Promise<void> {
// create "a" HTML element with href to file & click // create "a" HTML element with href to file & click
const link = document.createElement('a'); const link = document.createElement('a');
link.href = href; link.href = href;
link.setAttribute('download', `${filename}.csv`); link.setAttribute('download', `${taskId}.csv`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();

19
src/utils/index.ts

@ -1,4 +1,4 @@
import { AppEvents } from '@grafana/data'; import { AppEvents, TimeZone } from '@grafana/data';
// @ts-ignore // @ts-ignore
import appEvents from 'grafana/app/core/app_events'; 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 = { const options: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@ -24,5 +24,18 @@ export function convertTimestampToDate(timestamp: number): string {
minute: 'numeric', minute: 'numeric',
second: '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;
}
} }

Loading…
Cancel
Save