mirror of
https://github.com/grafana/grafana.git
synced 2026-08-14 07:04:57 -05:00
Dashboard: Add a feature that creates a table panel when a spreadsheet file is dropped on the dashboard. (#62688)
* drag files to dashboard * use file name as panel title * add file size limitation, file type limitation and error handling * Refactor file parsing for code sharing move accepted types and max size to file-import constants show which file types are allowed in file type error * update codeowners * Adjust max size to 1mb
This commit is contained in:
@@ -353,6 +353,7 @@ lerna.json @grafana/frontend-ops
|
||||
/public/app/features/dashboard/ @grafana/dashboards-squad
|
||||
/public/app/features/datasources/ @grafana/user-essentials
|
||||
/public/app/features/dimensions/ @grafana/grafana-edge-squad
|
||||
/public/app/features/dataframe-import/ @grafana/grafana-bi-squad
|
||||
/public/app/features/explore/ @grafana/explore-squad
|
||||
/public/app/features/expressions/ @grafana/observability-metrics
|
||||
/public/app/features/folders/ @grafana/user-essentials
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "17.0.2",
|
||||
"react-draggable": "4.4.5",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-grid-layout": "1.3.4",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-hook-form": "7.5.3",
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { PureComponent } from 'react';
|
||||
import DropZone, { FileRejection, DropEvent, ErrorCode } from 'react-dropzone';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil } from '@grafana/data';
|
||||
import {
|
||||
NavModel,
|
||||
NavModelItem,
|
||||
TimeRange,
|
||||
PageLayoutType,
|
||||
locationUtil,
|
||||
dataFrameToJSON,
|
||||
DataFrameJSON,
|
||||
GrafanaTheme2,
|
||||
getValueFormat,
|
||||
formattedValueToString,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { Icon, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
|
||||
@@ -14,8 +26,10 @@ import { getKioskMode } from 'app/core/navigation/kiosk';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state';
|
||||
import * as DFImport from 'app/features/dataframe-import';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage';
|
||||
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
||||
import { DashboardRoutes, KioskMode, StoreState } from 'app/types';
|
||||
import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
|
||||
|
||||
@@ -98,6 +112,60 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
private forceRouteReloadCounter = 0;
|
||||
state: State = this.getCleanState();
|
||||
|
||||
onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => {
|
||||
const grafanaDS = {
|
||||
type: 'grafana',
|
||||
uid: 'grafana',
|
||||
};
|
||||
DFImport.filesToDataframes(acceptedFiles).subscribe((next) => {
|
||||
const snapshot: DataFrameJSON[] = [];
|
||||
next.dataFrames.forEach((df) => {
|
||||
const dataframeJson = dataFrameToJSON(df);
|
||||
snapshot.push(dataframeJson);
|
||||
});
|
||||
this.props.dashboard?.addPanel({
|
||||
type: 'table',
|
||||
gridPos: { x: 0, y: 0, w: 12, h: 8 },
|
||||
title: next.file.name,
|
||||
datasource: grafanaDS,
|
||||
targets: [
|
||||
{
|
||||
queryType: GrafanaQueryType.Snapshot,
|
||||
snapshot,
|
||||
file: { name: next.file.name, size: next.file.size },
|
||||
datasource: grafanaDS,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
fileRejections.forEach((fileRejection) => {
|
||||
const errors = fileRejection.errors.map((error) => {
|
||||
switch (error.code) {
|
||||
case ErrorCode.FileTooLarge:
|
||||
const formattedSize = getValueFormat('decbytes')(DFImport.maxFileSize);
|
||||
return `File size must be less than ${formattedValueToString(formattedSize)}.`;
|
||||
case ErrorCode.FileInvalidType:
|
||||
return `File type must be one of the following types ${DFImport.formatFileTypes(DFImport.acceptedFiles)}.`;
|
||||
default:
|
||||
return error.message;
|
||||
}
|
||||
});
|
||||
this.props.notifyApp(
|
||||
createErrorNotification(
|
||||
`Failed to load ${fileRejection.file.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
<ul>
|
||||
{errors.map((err) => {
|
||||
return <li key={err}>{err}</li>;
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
getCleanState(): State {
|
||||
return {
|
||||
editPanel: null,
|
||||
@@ -378,21 +446,47 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
scrollTop={updateScrollTop}
|
||||
>
|
||||
<DashboardPrompt dashboard={dashboard} />
|
||||
|
||||
{initError && <DashboardFailed />}
|
||||
{showSubMenu && (
|
||||
<section aria-label={selectors.pages.Dashboard.SubMenu.submenu}>
|
||||
<SubMenu dashboard={dashboard} annotations={dashboard.annotations.list} links={dashboard.links} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
isEditable={!!dashboard.meta.canEdit}
|
||||
viewPanel={viewPanel}
|
||||
editPanel={editPanel}
|
||||
/>
|
||||
|
||||
{config.featureToggles.editPanelCSVDragAndDrop ? (
|
||||
<DropZone
|
||||
onDrop={this.onFileDrop}
|
||||
accept={DFImport.acceptedFiles}
|
||||
maxSize={DFImport.maxFileSize}
|
||||
noClick={true}
|
||||
>
|
||||
{({ getRootProps, isDragActive }) => {
|
||||
const styles = getStyles(this.props.theme, isDragActive);
|
||||
return (
|
||||
<div {...getRootProps({ className: styles.dropZone })}>
|
||||
<div className={styles.dropOverlay}>
|
||||
<div className={styles.dropHint}>
|
||||
<Icon name="upload" size="xxxl"></Icon>
|
||||
<h3>Create tables from spreadsheets</h3>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
isEditable={!!dashboard.meta.canEdit}
|
||||
viewPanel={viewPanel}
|
||||
editPanel={editPanel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</DropZone>
|
||||
) : (
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
isEditable={!!dashboard.meta.canEdit}
|
||||
viewPanel={viewPanel}
|
||||
editPanel={editPanel}
|
||||
/>
|
||||
)}
|
||||
{inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} />}
|
||||
</Page>
|
||||
{editPanel && (
|
||||
@@ -480,6 +574,32 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
|
||||
};
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, isDragActive: boolean) {
|
||||
return {
|
||||
dropZone: css`
|
||||
height: 100%;
|
||||
`,
|
||||
dropOverlay: css`
|
||||
background-color: ${isDragActive ? theme.colors.action.hover : `inherit`};
|
||||
border: ${isDragActive ? `2px dashed ${theme.colors.border.medium}` : 0};
|
||||
position: absolute;
|
||||
display: ${isDragActive ? 'flex' : 'none'};
|
||||
z-index: ${theme.zIndex.modal};
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
dropHint: css`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export const DashboardPage = withTheme2(UnthemedDashboardPage);
|
||||
DashboardPage.displayName = 'DashboardPage';
|
||||
export default connector(DashboardPage);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Accept } from 'react-dropzone';
|
||||
|
||||
export const acceptedFiles: Accept = {
|
||||
'text/plain': ['.csv', '.txt'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.apple.numbers': ['.numbers'],
|
||||
'application/vnd.oasis.opendocument.spreadsheet': ['.ods'],
|
||||
'application/json': ['.json'],
|
||||
};
|
||||
|
||||
//This should probably set from grafana conf
|
||||
export const maxFileSize = 1000000;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './utils';
|
||||
export * from './constants';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DataFrame } from '@grafana/data';
|
||||
|
||||
export interface FileImportResult {
|
||||
dataFrames: DataFrame[];
|
||||
file: File;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { formatFileTypes } from './utils';
|
||||
|
||||
describe('Dataframe import / Utils', () => {
|
||||
describe('formatFileTypes', () => {
|
||||
it('should nicely format file extensions', () => {
|
||||
expect(
|
||||
formatFileTypes({
|
||||
'text/plain': ['.csv', '.txt'],
|
||||
'application/json': ['.json'],
|
||||
})
|
||||
).toBe('.csv, .txt or .json');
|
||||
});
|
||||
|
||||
it('should remove duplicates', () => {
|
||||
expect(
|
||||
formatFileTypes({
|
||||
'text/plain': ['.csv', '.txt'],
|
||||
'application/json': ['.json', '.txt'],
|
||||
})
|
||||
).toBe('.csv, .txt or .json');
|
||||
});
|
||||
|
||||
it('should nicely format a single file type extension', () => {
|
||||
expect(
|
||||
formatFileTypes({
|
||||
'text/plain': ['.txt'],
|
||||
})
|
||||
).toBe('.txt');
|
||||
});
|
||||
|
||||
it('should nicely format two file type extension', () => {
|
||||
expect(
|
||||
formatFileTypes({
|
||||
'text/plain': ['.txt', '.csv'],
|
||||
})
|
||||
).toBe('.txt or .csv');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Accept } from 'react-dropzone';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { toDataFrame } from '@grafana/data';
|
||||
import { readSpreadsheet } from 'app/core/utils/sheet';
|
||||
|
||||
import { FileImportResult } from './types';
|
||||
|
||||
function getFileExtensions(acceptedFiles: Accept) {
|
||||
const fileExtentions = new Set<string>();
|
||||
Object.keys(acceptedFiles).forEach((v) => {
|
||||
acceptedFiles[v].forEach((extension) => {
|
||||
fileExtentions.add(extension);
|
||||
});
|
||||
});
|
||||
return fileExtentions;
|
||||
}
|
||||
|
||||
export function formatFileTypes(acceptedFiles: Accept) {
|
||||
const fileExtentions = Array.from(getFileExtensions(acceptedFiles));
|
||||
if (fileExtentions.length === 1) {
|
||||
return fileExtentions[0];
|
||||
}
|
||||
return `${fileExtentions.slice(0, -1).join(', ')} or ${fileExtentions.slice(-1)}`;
|
||||
}
|
||||
|
||||
export function filesToDataframes(files: File[]): Observable<FileImportResult> {
|
||||
return new Observable<FileImportResult>((subscriber) => {
|
||||
let completedFiles = 0;
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file);
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (result && result instanceof ArrayBuffer) {
|
||||
if (file.type === 'application/json') {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const json = JSON.parse(decoder.decode(result));
|
||||
subscriber.next({ dataFrames: [toDataFrame(json)], file: file });
|
||||
} else {
|
||||
subscriber.next({ dataFrames: readSpreadsheet(result), file: file });
|
||||
}
|
||||
if (++completedFiles >= files.length) {
|
||||
subscriber.complete();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import pluralize from 'pluralize';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { DropEvent, FileRejection } from 'react-dropzone';
|
||||
|
||||
import {
|
||||
QueryEditorProps,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
withTheme2,
|
||||
} from '@grafana/ui';
|
||||
import { hasAlphaPanels } from 'app/core/config';
|
||||
import { readSpreadsheet } from 'app/core/utils/sheet';
|
||||
import * as DFImport from 'app/features/dataframe-import';
|
||||
import { SearchQuery } from 'app/features/search/service';
|
||||
|
||||
import { GrafanaDatasource } from '../datasource';
|
||||
@@ -376,8 +377,21 @@ export class UnthemedQueryEditor extends PureComponent<Props, State> {
|
||||
return null;
|
||||
};
|
||||
|
||||
onDropAccepted = (files: File[]) => {
|
||||
this.props.onChange({ ...this.props.query, file: { name: files[0].name, size: files[0].size } });
|
||||
onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => {
|
||||
DFImport.filesToDataframes(acceptedFiles).subscribe((next) => {
|
||||
const snapshot: DataFrameJSON[] = [];
|
||||
next.dataFrames.forEach((df) => {
|
||||
const dataframeJson = dataFrameToJSON(df);
|
||||
snapshot.push(dataframeJson);
|
||||
});
|
||||
this.props.onChange({
|
||||
...this.props.query,
|
||||
file: { name: next.file.name, size: next.file.size },
|
||||
queryType: GrafanaQueryType.Snapshot,
|
||||
snapshot,
|
||||
});
|
||||
this.props.onRunQuery();
|
||||
});
|
||||
};
|
||||
|
||||
renderSnapshotQuery() {
|
||||
@@ -399,18 +413,11 @@ export class UnthemedQueryEditor extends PureComponent<Props, State> {
|
||||
readAs="readAsArrayBuffer"
|
||||
fileListRenderer={this.fileListRenderer}
|
||||
options={{
|
||||
onDropAccepted: this.onDropAccepted,
|
||||
maxSize: 200000,
|
||||
onDrop: this.onFileDrop,
|
||||
maxSize: DFImport.maxFileSize,
|
||||
multiple: false,
|
||||
accept: {
|
||||
'text/plain': ['.csv', '.txt'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.apple.numbers': ['.numbers'],
|
||||
'application/vnd.oasis.opendocument.spreadsheet': ['.ods'],
|
||||
},
|
||||
accept: DFImport.acceptedFiles,
|
||||
}}
|
||||
onLoad={this.onFileDrop}
|
||||
>
|
||||
<FileDropzoneDefaultChildren primaryText={this.props?.query?.file ? 'Replace file' : 'Upload file'} />
|
||||
</FileDropzone>
|
||||
@@ -438,28 +445,6 @@ export class UnthemedQueryEditor extends PureComponent<Props, State> {
|
||||
onRunQuery();
|
||||
};
|
||||
|
||||
onFileDrop = (result: ArrayBuffer | String | null) => {
|
||||
const snapshot: DataFrameJSON[] = [];
|
||||
|
||||
if (result) {
|
||||
if (!result || result instanceof String) {
|
||||
return;
|
||||
}
|
||||
const dataFrames = readSpreadsheet(result);
|
||||
dataFrames.forEach((df) => {
|
||||
const dataframeJson = dataFrameToJSON(df);
|
||||
snapshot.push(dataframeJson);
|
||||
});
|
||||
}
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.query,
|
||||
queryType: GrafanaQueryType.Snapshot,
|
||||
snapshot,
|
||||
});
|
||||
this.props.onRunQuery();
|
||||
};
|
||||
|
||||
render() {
|
||||
const query = {
|
||||
...defaultQuery,
|
||||
|
||||
@@ -22209,6 +22209,7 @@ __metadata:
|
||||
react-diff-viewer: ^3.1.1
|
||||
react-dom: 17.0.2
|
||||
react-draggable: 4.4.5
|
||||
react-dropzone: ^14.2.3
|
||||
react-grid-layout: 1.3.4
|
||||
react-highlight-words: 0.20.0
|
||||
react-hook-form: 7.5.3
|
||||
@@ -32732,7 +32733,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dropzone@npm:14.2.3":
|
||||
"react-dropzone@npm:14.2.3, react-dropzone@npm:^14.2.3":
|
||||
version: 14.2.3
|
||||
resolution: "react-dropzone@npm:14.2.3"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user