From da1701ce576ab26506d6256567b73a597043623a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 14 Jul 2022 15:36:17 -0700 Subject: [PATCH] Dashboards: load from storage (#51949) --- .betterer.results | 9 +- .../src/types/featureToggles.gen.ts | 1 + pkg/api/api.go | 4 + pkg/services/featuremgmt/registry.go | 5 ++ pkg/services/featuremgmt/toggles_gen.go | 4 + public/app/core/services/backend_srv.ts | 8 +- .../SaveDashboard/SaveDashboardDrawer.tsx | 10 ++- .../components/SaveDashboard/types.ts | 8 ++ .../SaveDashboard/useDashboardSave.tsx | 3 + .../dashboard/services/DashboardLoaderSrv.ts | 4 + .../features/dashboard/state/initDashboard.ts | 4 + .../manage-dashboards/state/actions.ts | 17 ++-- public/app/features/storage/FileView.tsx | 2 +- .../features/storage/StorageFolderPage.tsx | 79 +++++++++++++++++ public/app/features/storage/StoragePage.tsx | 13 ++- public/app/features/storage/UploadView.tsx | 2 +- .../storage/{helper.ts => storage.ts} | 87 +++++++++++++++++-- public/app/routes/routes.tsx | 23 +++++ public/app/types/dashboard.ts | 7 ++ 19 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 public/app/features/storage/StorageFolderPage.tsx rename public/app/features/storage/{helper.ts => storage.ts} (62%) diff --git a/.betterer.results b/.betterer.results index cb6e137d371..c5d0b69eb13 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5843,12 +5843,15 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/storage/helper.ts:5381": [ + "public/app/features/storage/storage.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Unexpected any. Specify a different type.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"] ], "public/app/features/teams/CreateTeam.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 4b1ab6703d0..13624b12500 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -41,6 +41,7 @@ export interface FeatureToggles { annotationComments?: boolean; migrationLocking?: boolean; storage?: boolean; + dashboardsFromStorage?: boolean; export?: boolean; storageLocalUpload?: boolean; azureMonitorResourcePickerForMetrics?: boolean; diff --git a/pkg/api/api.go b/pkg/api/api.go index 5cf5e8e2c1b..b5cacdb4915 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -108,6 +108,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboards/*", reqSignedIn, hs.Index) r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) + if hs.Features.IsEnabled(featuremgmt.FlagDashboardsFromStorage) { + r.Get("/g/*", reqSignedIn, hs.Index) + } + if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { r.Get("/public-dashboards/:accessToken", publicdashboardsapi.SetPublicDashboardFlag(), hs.Index) } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2dd5610fd8f..2ce3890e4d0 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -141,6 +141,11 @@ var ( Description: "Configurable storage for dashboards, datasources, and resources", State: FeatureStateAlpha, }, + { + Name: "dashboardsFromStorage", + Description: "Load dashboards from the generic storage interface", + State: FeatureStateAlpha, + }, { Name: "export", Description: "Export grafana instance (to git, etc)", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f8f43947430..aa9c44edccf 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -107,6 +107,10 @@ const ( // Configurable storage for dashboards, datasources, and resources FlagStorage = "storage" + // FlagDashboardsFromStorage + // Load dashboards from the generic storage interface + FlagDashboardsFromStorage = "dashboardsFromStorage" + // FlagExport // Export grafana instance (to git, etc) FlagExport = "export" diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index d0c67af38ab..6e8f20cdf6b 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -14,10 +14,11 @@ import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, th import { v4 as uuidv4 } from 'uuid'; import { AppEvents, DataQueryErrorType } from '@grafana/data'; -import { BackendSrv as BackendService, BackendSrvRequest, FetchError, FetchResponse } from '@grafana/runtime'; +import { BackendSrv as BackendService, BackendSrvRequest, config, FetchError, FetchResponse } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { getConfig } from 'app/core/config'; import { DashboardSearchHit } from 'app/features/search/types'; +import { getGrafanaStorage } from 'app/features/storage/storage'; import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal'; import { DashboardDTO, FolderDTO } from 'app/types'; @@ -430,7 +431,10 @@ export class BackendSrv implements BackendService { return this.get('/api/search', query); } - getDashboardByUid(uid: string) { + getDashboardByUid(uid: string): Promise { + if (uid.indexOf('/') > 0 && config.featureToggles.dashboardsFromStorage) { + return getGrafanaStorage().getDashboard(uid); + } return this.get(`/api/dashboards/uid/${uid}`); } diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx index f102e8635d1..05f0c2abad0 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from 'react'; import { useAsync } from 'react-use'; -import { Drawer, Tab, TabsBar } from '@grafana/ui'; +import { Drawer, Spinner, Tab, TabsBar } from '@grafana/ui'; import { backendSrv } from 'app/core/services/backend_srv'; import { jsonDiff } from '../VersionHistory/utils'; @@ -70,6 +70,14 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop return ; } + if (state.loading) { + return ( +
+ +
+ ); + } + if (isNew || isCopy) { return ( void; diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index d864cc9f0a3..3b17e356235 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -36,6 +36,9 @@ export const useDashboardSave = (dashboard: DashboardModel) => { const notifyApp = useAppNotification(); useEffect(() => { + if (state.error) { + notifyApp.error(state.error.message ?? 'Error saving dashboard'); + } if (state.value) { dashboard.version = state.value.version; dashboard.clearUnsavedChanges(); diff --git a/public/app/features/dashboard/services/DashboardLoaderSrv.ts b/public/app/features/dashboard/services/DashboardLoaderSrv.ts index 1b34121fea0..a2f781cd177 100644 --- a/public/app/features/dashboard/services/DashboardLoaderSrv.ts +++ b/public/app/features/dashboard/services/DashboardLoaderSrv.ts @@ -8,6 +8,8 @@ import { backendSrv } from 'app/core/services/backend_srv'; import impressionSrv from 'app/core/services/impression_srv'; import kbn from 'app/core/utils/kbn'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { getGrafanaStorage } from 'app/features/storage/storage'; +import { DashboardRoutes } from 'app/types'; import { appEvents } from '../../../core/core'; @@ -39,6 +41,8 @@ export class DashboardLoaderSrv { promise = backendSrv.get('/api/snapshots/' + slug).catch(() => { return this._dashboardLoadFailed('Snapshot not found', true); }); + } else if (type === DashboardRoutes.Path) { + promise = getGrafanaStorage().getDashboard(slug!); } else if (type === 'ds') { promise = this._loadFromDatasource(slug); // explore dashboards as code } else if (type === 'public') { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 6c723db030b..2902bdfbd43 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -87,6 +87,10 @@ async function fetchDashboard( case DashboardRoutes.New: { return getNewDashboardModelData(args.urlFolderId, args.panelType); } + case DashboardRoutes.Path: { + const path = args.urlSlug ?? ''; + return await dashboardLoaderSrv.loadDashboard(DashboardRoutes.Path, path, path); + } default: throw { message: 'Unknown route ' + args.routeName }; } diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 53a892dac93..39ccd8833d8 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -2,8 +2,10 @@ import { DataSourceInstanceSettings, locationUtil } from '@grafana/data'; import { getDataSourceSrv, locationService, getBackendSrv, isFetchError } from '@grafana/runtime'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; -import { DashboardDataDTO, DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types'; +import { getGrafanaStorage } from 'app/features/storage/storage'; +import { DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types'; import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter'; import { getLibraryPanel } from '../../library-panels/state/api'; @@ -262,16 +264,13 @@ export function deleteFoldersAndDashboards(folderUids: string[], dashboardUids: return executeInOrder(tasks); } -export interface SaveDashboardOptions { - dashboard: DashboardDataDTO; - message?: string; - folderId?: number; - overwrite?: boolean; -} - -export function saveDashboard(options: SaveDashboardOptions) { +export function saveDashboard(options: SaveDashboardCommand) { dashboardWatcher.ignoreNextSave(); + if (options.dashboard.uid.indexOf('/') > 0) { + return getGrafanaStorage().saveDashboard(options); + } + return getBackendSrv().post('/api/dashboards/db/', { dashboard: options.dashboard, message: options.message ?? '', diff --git a/public/app/features/storage/FileView.tsx b/public/app/features/storage/FileView.tsx index 9051b4acec2..df22b93e951 100644 --- a/public/app/features/storage/FileView.tsx +++ b/public/app/features/storage/FileView.tsx @@ -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 { diff --git a/public/app/features/storage/StorageFolderPage.tsx b/public/app/features/storage/StorageFolderPage.tsx new file mode 100644 index 00000000000..74a37b30ffe --- /dev/null +++ b/public/app/features/storage/StorageFolderPage.tsx @@ -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) => { + const slug = props.match.params.slug; + + const styles = useStyles2(getStyles); + const listing = useAsync((): Promise => { + 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 ( + + {name} + + + + + ); + }); + } + if (listing.loading) { + return ; + } + return
?
; + }; + + return ( +
+ {slug?.length > 0 && ( + <> +

{slug}

+ + {parent} + + + + +
+ + )} + {renderListing()} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + margin: 50px; + `, +}); + +export default StorageFolderPage; diff --git a/public/app/features/storage/StoragePage.tsx b/public/app/features/storage/StoragePage.tsx index 81f94cf045d..ca6dd94bd8f 100644 --- a/public/app/features/storage/StoragePage.tsx +++ b/public/app/features/storage/StoragePage.tsx @@ -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 (
+ {canViewDashboard && ( + + Dashboard + + )} {canAddFolder && } {canDelete && (