Dashboards: load from storage (#51949)

This commit is contained in:
Ryan McKinley
2022-07-14 15:36:17 -07:00
committed by GitHub
parent eab03aa207
commit da1701ce57
19 changed files with 262 additions and 28 deletions

View File

@ -8,7 +8,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, useStyles2 } from '@grafana/ui';
import { getGrafanaStorage } from './helper';
import { getGrafanaStorage } from './storage';
import { StorageView } from './types';
interface FileDisplayInfo {

View File

@ -0,0 +1,79 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { useAsync } from 'react-use';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Card, Icon, Spinner, useStyles2 } from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getGrafanaStorage } from './storage';
export interface Props extends GrafanaRouteComponentProps<{ slug: string }> {}
export const StorageFolderPage: FC<Props> = (props) => {
const slug = props.match.params.slug;
const styles = useStyles2(getStyles);
const listing = useAsync((): Promise<DataFrame | undefined> => {
return getGrafanaStorage().list(slug);
}, [slug]);
let base = document.location.pathname;
if (!base.endsWith('/')) {
base += '/';
}
let parent = '';
const idx = base.lastIndexOf('/', base.length - 2);
if (idx > 0) {
parent = base.substring(0, idx);
}
const renderListing = () => {
if (listing.value) {
const names = listing.value.fields[0].values.toArray();
return names.map((item: string) => {
let name = item;
const isFolder = name.indexOf('.') < 0;
const isDash = !isFolder && name.endsWith('.json');
return (
<Card key={name} href={isFolder || isDash ? base + name : undefined}>
<Card.Heading>{name}</Card.Heading>
<Card.Figure>
<Icon name={isFolder ? 'folder' : isDash ? 'gf-grid' : 'file-alt'} size="sm" />
</Card.Figure>
</Card>
);
});
}
if (listing.loading) {
return <Spinner />;
}
return <div>?</div>;
};
return (
<div className={styles.wrapper}>
{slug?.length > 0 && (
<>
<h1>{slug}</h1>
<Card href={parent}>
<Card.Heading>{parent}</Card.Heading>
<Card.Figure>
<Icon name="arrow-left" size="sm" />
</Card.Figure>
</Card>
<br />
</>
)}
{renderListing()}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
margin: 50px;
`,
});
export default StorageFolderPage;

View File

@ -3,8 +3,8 @@ import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { DataFrame, GrafanaTheme2, isDataFrame, ValueLinkConfig } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useStyles2, IconName, Spinner, TabsBar, Tab, Button, HorizontalGroup } from '@grafana/ui';
import { config, locationService } from '@grafana/runtime';
import { useStyles2, IconName, Spinner, TabsBar, Tab, Button, HorizontalGroup, LinkButton } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
@ -18,7 +18,7 @@ import { ExportView } from './ExportView';
import { FileView } from './FileView';
import { FolderView } from './FolderView';
import { RootView } from './RootView';
import { getGrafanaStorage, filenameAlreadyExists } from './helper';
import { getGrafanaStorage, filenameAlreadyExists } from './storage';
import { StorageView } from './types';
interface RouteParams {
@ -162,12 +162,19 @@ export default function StoragePage(props: Props) {
}
const canAddFolder = isFolder && path.startsWith('resources');
const canDelete = path.startsWith('resources/');
const canViewDashboard =
path.startsWith('devenv/') && config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
return (
<div className={styles.wrapper}>
<HorizontalGroup width="100%" justify="space-between" spacing={'md'} height={25}>
<Breadcrumb pathName={path} onPathChange={setPath} rootIcon={navModel.node.icon as IconName} />
<HorizontalGroup>
{canViewDashboard && (
<LinkButton icon="dashboard" href={`g/${path}`}>
Dashboard
</LinkButton>
)}
{canAddFolder && <Button onClick={() => setIsAddingNewFolder(true)}>New Folder</Button>}
{canDelete && (
<Button

View File

@ -5,7 +5,7 @@ import SVG from 'react-inlinesvg';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, ButtonGroup, Checkbox, Field, FileDropzone, useStyles2 } from '@grafana/ui';
import { filenameAlreadyExists, getGrafanaStorage } from './helper';
import { filenameAlreadyExists, getGrafanaStorage } from './storage';
import { UploadReponse } from './types';
interface Props {

View File

@ -1,5 +1,8 @@
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { DashboardDTO } from 'app/types';
import { UploadReponse } from './types';
@ -10,6 +13,13 @@ export interface GrafanaStorage {
upload: (folder: string, file: File, overwriteExistingFile: boolean) => Promise<UploadReponse>;
createFolder: (path: string) => Promise<{ error?: string }>;
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
/**
* Temporary shim that will return a DashboardDTO shape for files in storage
* Longer term, this will call an "Entity API" that is eventually backed by storage
*/
getDashboard: (path: string) => Promise<DashboardDTO>;
saveDashboard: (options: SaveDashboardCommand) => Promise<any>;
}
class SimpleStorage implements GrafanaStorage {
@ -103,6 +113,75 @@ class SimpleStorage implements GrafanaStorage {
}
return body;
}
// Temporary shim that can be loaded into the existing dashboard page structure
async getDashboard(path: string): Promise<DashboardDTO> {
if (!config.featureToggles.dashboardsFromStorage) {
return Promise.reject('Dashboards from storage is not enabled');
}
if (!path.endsWith('.json')) {
path += '.json';
}
const result = await backendSrv.get(`/api/storage/read/${path}`);
result.uid = path;
delete result.id; // Saved with the dev dashboards!
return {
meta: {
uid: path,
slug: path,
canEdit: true,
canSave: true,
canStar: false, // needs id
},
dashboard: result,
};
}
async saveDashboard(options: SaveDashboardCommand): Promise<any> {
if (!config.featureToggles.dashboardsFromStorage) {
return Promise.reject('Dashboards from storage is not enabled');
}
const blob = new Blob([JSON.stringify(options.dashboard)], {
type: 'application/json',
});
const uid = options.dashboard.uid;
const formData = new FormData();
if (options.message) {
formData.append('message', options.message);
}
formData.append('overwriteExistingFile', options.overwrite === false ? 'false' : 'true');
formData.append('file.path', uid);
formData.append('file', blob);
const res = await fetch('/api/storage/upload', {
method: 'POST',
body: formData,
});
let body = (await res.json()) as UploadReponse;
if (res.status !== 200 && !body?.err) {
console.log('SAVE', options, body);
return Promise.reject({ message: body?.message ?? res.statusText });
}
return {
uid,
url: `/g/${uid}`,
slug: uid,
status: 'success',
};
}
}
export function filenameAlreadyExists(folderName: string, fileNames: string[]) {
const lowerCase = folderName.toLowerCase();
const trimmedLowerCase = lowerCase.trim();
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
return existingTrimmedLowerCaseNames.includes(trimmedLowerCase);
}
let storage: GrafanaStorage | undefined;
@ -113,11 +192,3 @@ export function getGrafanaStorage() {
}
return storage;
}
export function filenameAlreadyExists(folderName: string, fileNames: string[]) {
const lowerCase = folderName.toLowerCase();
const trimmedLowerCase = lowerCase.trim();
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
return existingTrimmedLowerCaseNames.includes(trimmedLowerCase);
}