Dashboards: Change the way dashboard not found error is handled (#98950)

* Get rid of _dashboardLoadFailed

* Get rid of dashboardNotFound meta

* Update public dashboards tests

* Fix DashboardPage tests

* DashboardPageProxy tests

* DashboardScenePageStateManager test fix

* Beterer

* Fix merge

* Nits

* Fix test

* remove debugger

* Update get folder to throw

* translate error title

* Update public/app/features/apiserver/types.ts

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>

* Update public/app/features/dashboard/services/DashboardLoaderSrv.ts

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>

* Update public/app/features/dashboard/services/DashboardLoaderSrv.ts

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>

* Update public/app/features/dashboard/services/DashboardLoaderSrv.ts

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>

* Betterer

* Update test cases

* More test updates

* More translations

---------

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>
Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
pull/99575/head
Dominik Prokop 11 months ago committed by GitHub
parent 571f20676d
commit ae62b3817b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .betterer.results
  2. 15
      public/app/core/utils/errors.test.ts
  3. 41
      public/app/core/utils/errors.ts
  4. 1
      public/app/features/apiserver/types.ts
  5. 6
      public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx
  6. 60
      public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
  7. 25
      public/app/features/dashboard-scene/pages/DashboardScenePage.tsx
  8. 38
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts
  9. 57
      public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
  10. 47
      public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx
  11. 50
      public/app/features/dashboard-scene/pages/PublicDashboardScenePage.tsx
  12. 4
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  13. 41
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx
  14. 19
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
  15. 8
      public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx
  16. 7
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  17. 2
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts
  18. 15
      public/app/features/dashboard-scene/solo/SoloPanelPage.tsx
  19. 20
      public/app/features/dashboard-scene/utils/test-utils.ts
  20. 7
      public/app/features/dashboard/api/legacy.test.ts
  21. 9
      public/app/features/dashboard/api/legacy.ts
  22. 7
      public/app/features/dashboard/api/v0.test.ts
  23. 63
      public/app/features/dashboard/api/v0.ts
  24. 10
      public/app/features/dashboard/api/v2.test.ts
  25. 61
      public/app/features/dashboard/api/v2.ts
  26. 32
      public/app/features/dashboard/containers/DashboardPage.tsx
  27. 30
      public/app/features/dashboard/containers/DashboardPageError.tsx
  28. 81
      public/app/features/dashboard/containers/DashboardPageProxy.test.tsx
  29. 11
      public/app/features/dashboard/containers/DashboardPageProxy.tsx
  30. 47
      public/app/features/dashboard/containers/PublicDashboardPage.test.tsx
  31. 43
      public/app/features/dashboard/containers/PublicDashboardPage.tsx
  32. 175
      public/app/features/dashboard/services/DashboardLoaderSrv.ts
  33. 10
      public/app/features/dashboard/services/SnapshotSrv.ts
  34. 1
      public/app/types/dashboard.ts
  35. 3
      public/locales/en-US/grafana.json
  36. 3
      public/locales/pseudo-LOCALE/grafana.json

@ -3236,9 +3236,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]
],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
@ -3281,8 +3278,7 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/features/dashboard-scene/panel-edit/LibraryVizPanelInfo.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@ -4213,6 +4209,9 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/dashboard/containers/PublicDashboardPage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard/containers/SoloPanelPage.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]

@ -1,5 +1,6 @@
import { FetchError } from '@grafana/runtime';
import { getMessageFromError } from 'app/core/utils/errors';
import { LoadError } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
describe('errors functions', () => {
let message: string | null;
@ -53,4 +54,18 @@ describe('errors functions', () => {
expect(message).toBe('{"customError":"error string"}');
});
});
describe('when getMessageFromError gets an LoadError object', () => {
beforeEach(() => {
const error: LoadError = {
message: 'error string',
status: 500,
};
message = getMessageFromError(error);
});
it('should return the stringified error', () => {
expect(message).toBe('error string');
});
});
});

@ -14,8 +14,49 @@ export function getMessageFromError(err: unknown): string {
} else if (err.statusText) {
return err.statusText;
}
} else if (err.hasOwnProperty('message')) {
// @ts-expect-error
return err.message;
}
}
return JSON.stringify(err);
}
export function getStatusFromError(err: unknown): number | undefined {
if (typeof err === 'string') {
return undefined;
}
if (err) {
if (err instanceof Error) {
return undefined;
} else if (isFetchError(err)) {
return err.status;
} else if (err.hasOwnProperty('status')) {
// @ts-expect-error
return err.status;
}
}
return undefined;
}
export function getMessageIdFromError(err: unknown): string | undefined {
if (typeof err === 'string') {
return undefined;
}
if (err) {
if (err instanceof Error) {
return undefined;
} else if (isFetchError(err)) {
return err.data?.messageId;
} else if (err.hasOwnProperty('messageId')) {
// @ts-expect-error
return err.messageId;
}
}
return undefined;
}

@ -82,7 +82,6 @@ type GrafanaClientAnnotations = {
[AnnoKeyFolderId]?: number;
[AnnoKeyFolderId]?: number;
[AnnoKeySavedFromUI]?: string;
[AnnoKeyDashboardNotFound]?: boolean;
[AnnoKeyDashboardIsSnapshot]?: boolean;
[AnnoKeyDashboardSnapshotOriginalUrl]?: string;

@ -5,6 +5,8 @@ import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { EmbeddedDashboardProps } from '@grafana/runtime';
import { SceneObjectStateChangedEvent, sceneUtils } from '@grafana/scenes';
import { Spinner, Alert, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { getMessageFromError } from 'app/core/utils/errors';
import { DashboardRoutes } from 'app/types';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
@ -23,8 +25,8 @@ export function EmbeddedDashboard(props: EmbeddedDashboardProps) {
if (loadError) {
return (
<Alert severity="error" title="Failed to load dashboard">
{loadError}
<Alert severity="error" title={t('dashboard.errors.failed-to-load', 'Failed to load dashboard')}>
{getMessageFromError(loadError)}
</Alert>
);
}

@ -7,6 +7,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { PanelProps } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors } from '@grafana/e2e-selectors';
import {
LocationServiceProvider,
config,
@ -23,6 +24,7 @@ import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboar
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { setupLoadDashboardMockReject, setupLoadDashboardRuntimeErrorMock } from '../utils/test-utils';
import { DashboardScenePage, Props } from './DashboardScenePage';
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
@ -298,6 +300,64 @@ describe('DashboardScenePage', () => {
await waitFor(() => expect(screen.queryByText('Last 6 hours')).toBeInTheDocument());
});
});
describe('errors rendering', () => {
it('should render dashboard not found notice when dashboard... not found', async () => {
setupLoadDashboardMockReject({
status: 404,
statusText: 'Not Found',
data: {
message: 'Dashboard not found',
},
config: {
method: 'GET',
url: 'api/dashboards/uid/adfjq9edwm0hsdsa',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
},
hideFromInspector: true,
},
isHandled: true,
});
setup();
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
});
it('should render error alert for backend errors', async () => {
setupLoadDashboardMockReject({
status: 500,
statusText: 'internal server error',
data: {
message: 'Internal server error',
},
config: {
method: 'GET',
url: 'api/dashboards/uid/adfjq9edwm0hsdsa',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
},
hideFromInspector: true,
},
isHandled: true,
});
setup();
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Internal server error');
});
it('should render error alert for runtime errors', async () => {
setupLoadDashboardRuntimeErrorMock();
setup();
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Runtime error');
});
});
});
interface VizOptions {

@ -6,10 +6,11 @@ import { usePrevious } from 'react-use';
import { PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Alert, Box } from '@grafana/ui';
import { Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DashboardPageError } from 'app/features/dashboard/containers/DashboardPageError';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
import { DashboardRoutes } from 'app/types';
@ -48,17 +49,19 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
}, [stateManager, uid, route.routeName, queryParams.folderUid, routeReloadCounter, slug, type]);
if (!dashboard) {
let errorElement;
if (loadError) {
errorElement = <DashboardPageError error={loadError} type={type} />;
}
return (
<Page navId="dashboards/browse" layout={PageLayoutType.Canvas} data-testid={'dashboard-scene-page'}>
<Box paddingY={4} display="flex" direction="column" alignItems="center">
{isLoading && <PageLoader />}
{loadError && (
<Alert title="Dashboard failed to load" severity="error" data-testid="dashboard-not-found">
{loadError}
</Alert>
)}
</Box>
</Page>
errorElement || (
<Page navId="dashboards/browse" layout={PageLayoutType.Canvas} data-testid={'dashboard-scene-page'}>
<Box paddingY={4} display="flex" direction="column" alignItems="center">
{isLoading && <PageLoader />}
</Box>
</Page>
)
);
}

@ -9,7 +9,7 @@ import { getDashboardSnapshotSrv } from 'app/features/dashboard/services/Snapsho
import { DASHBOARD_FROM_LS_KEY, DashboardRoutes } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { setupLoadDashboardMock } from '../utils/test-utils';
import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils';
import {
DashboardScenePageStateManager,
@ -45,14 +45,34 @@ describe('DashboardScenePageStateManager v1', () => {
});
it("should error when the dashboard doesn't exist", async () => {
setupLoadDashboardMock({ dashboard: undefined, meta: {} });
setupLoadDashboardMockReject({
status: 404,
statusText: 'Not Found',
data: {
message: 'Dashboard not found',
},
config: {
method: 'GET',
url: 'api/dashboards/uid/adfjq9edwm0hsdsa',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
},
hideFromInspector: true,
},
isHandled: true,
});
const loader = new DashboardScenePageStateManager({});
await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.isLoading).toBe(false);
expect(loader.state.loadError).toBe('Dashboard not found');
expect(loader.state.loadError).toEqual({
status: 404,
messageId: undefined,
message: 'Dashboard not found',
});
});
it('should clear current dashboard while loading next', async () => {
@ -128,7 +148,11 @@ describe('DashboardScenePageStateManager v1', () => {
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual('Failed to load home dashboard');
expect(loader.state.loadError).toEqual({
message: 'Failed to load home dashboard',
messageId: undefined,
status: 500,
});
});
});
@ -425,7 +449,11 @@ describe('DashboardScenePageStateManager v2', () => {
await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home });
expect(loader.state.dashboard).toBeUndefined();
expect(loader.state.loadError).toEqual('Failed to load home dashboard');
expect(loader.state.loadError).toEqual({
message: 'Failed to load home dashboard',
messageId: undefined,
status: 500,
});
});
});

@ -4,7 +4,7 @@ import { locationUtil, UrlQueryMap } from '@grafana/data';
import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { getMessageFromError } from 'app/core/utils/errors';
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
@ -24,12 +24,18 @@ import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSession
import { updateNavModel } from './utils';
export interface LoadError {
status?: number;
messageId?: string;
message: string;
}
export interface DashboardScenePageState {
dashboard?: DashboardScene;
options?: LoadDashboardOptions;
panelEditor?: PanelEditor;
isLoading?: boolean;
loadError?: string;
loadError?: LoadError;
}
export const DASHBOARD_CACHE_TTL = 500;
@ -48,6 +54,7 @@ interface DashboardCacheEntry<T> {
export interface LoadDashboardOptions {
uid: string;
route: DashboardRoutes;
type?: string;
urlFolderUid?: string;
params?: {
version: number;
@ -99,7 +106,18 @@ abstract class DashboardScenePageStateManagerBase<T>
this.setState({ dashboard: dashboard, isLoading: false });
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
const status = getStatusFromError(err);
const message = getMessageFromError(err);
const messageId = getMessageIdFromError(err);
this.setState({
isLoading: false,
loadError: {
status,
message,
messageId,
},
});
}
}
@ -128,8 +146,17 @@ abstract class DashboardScenePageStateManagerBase<T>
});
}
} catch (err) {
const msg = getMessageFromError(err);
this.setState({ isLoading: false, loadError: msg });
const status = getStatusFromError(err);
const message = getMessageFromError(err);
const messageId = getMessageIdFromError(err);
this.setState({
isLoading: false,
loadError: {
status,
message,
messageId,
},
});
}
}
@ -351,7 +378,13 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
}
if (!rsp?.dashboard) {
this.setState({ isLoading: false, loadError: 'Dashboard not found' });
this.setState({
isLoading: false,
loadError: {
status: 404,
message: 'Dashboard not found',
},
});
return;
}
@ -361,8 +394,15 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
this.setState({ dashboard: scene, isLoading: false, options });
} catch (err) {
const msg = getMessageFromError(err);
this.setState({ isLoading: false, loadError: msg });
const status = getStatusFromError(err);
const message = getMessageFromError(err);
this.setState({
isLoading: false,
loadError: {
message,
status,
},
});
}
}
}
@ -429,7 +469,6 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
urlFolderUid,
params,
}: LoadDashboardOptions): Promise<DashboardWithAccessInfo<DashboardV2Spec> | null> {
// throw new Error('Method not implemented.');
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
if (!params) {
const cachedDashboard = this.getDashboardFromCache(cacheKey);

@ -11,7 +11,7 @@ import { Dashboard } from '@grafana/schema';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { DashboardRoutes } from 'app/types/dashboard';
import { setupLoadDashboardMock } from '../utils/test-utils';
import { setupLoadDashboardMock, setupLoadDashboardMockReject } from '../utils/test-utils';
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
import { PublicDashboardScenePage, Props as PublicDashboardSceneProps } from './PublicDashboardScenePage';
@ -192,10 +192,27 @@ describe('given unavailable public dashboard', () => {
it('renders public dashboard paused screen when it is paused', async () => {
const accessToken = 'paused-pubdash-access-token';
config.publicDashboardAccessToken = accessToken;
setupLoadDashboardMock({
dashboard: simpleDashboard,
meta: { publicDashboardEnabled: false, dashboardNotFound: false },
setupLoadDashboardMockReject({
status: 403,
statusText: 'Forbidden',
data: {
statusCode: 403,
messageId: 'publicdashboards.notEnabled',
message: 'Dashboard paused',
},
config: {
method: 'GET',
url: 'api/public/dashboards/ce159fe139fc4d238a7d9c3ae33fb82b',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
'X-Grafana-Device-Id': 'da48fad0e58ba327fd7d1e6bd17e9c63',
},
hideFromInspector: true,
},
});
setup(accessToken);
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));
@ -208,10 +225,26 @@ describe('given unavailable public dashboard', () => {
it('renders public dashboard not available screen when it is deleted', async () => {
const accessToken = 'deleted-pubdash-access-token';
config.publicDashboardAccessToken = accessToken;
setupLoadDashboardMock({
dashboard: simpleDashboard,
meta: { dashboardNotFound: true },
setupLoadDashboardMockReject({
status: 404,
statusText: 'Not Found',
data: {
statusCode: 404,
messageId: 'publicdashboards.notFound',
message: 'Dashboard not found',
},
config: {
method: 'GET',
url: 'api/public/dashboards/ce159fe139fc4d238a7d9c3ae33fb82b',
retry: 0,
hideFromInspector: true,
headers: {
'X-Grafana-Device-Id': 'da48fad0e58ba327fd7d1e6bd17e9c63',
},
},
});
setup(accessToken);
await waitForElementToBeRemoved(screen.getByTestId(publicDashboardSceneSelector.loadingPage));

@ -5,7 +5,7 @@ import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, UrlSyncContextProvider } from '@grafana/scenes';
import { Icon, Stack, useStyles2 } from '@grafana/ui';
import { Alert, Box, Icon, Stack, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@ -15,11 +15,12 @@ import {
PublicDashboardPageRouteParams,
PublicDashboardPageRouteSearchParams,
} from 'app/features/dashboard/containers/types';
import { AppNotificationSeverity } from 'app/types';
import { DashboardRoutes } from 'app/types/dashboard';
import { DashboardScene } from '../scene/DashboardScene';
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
import { getDashboardScenePageStateManager, LoadError } from './DashboardScenePageStateManager';
const selectors = e2eSelectors.pages.PublicDashboardScene;
@ -42,23 +43,18 @@ export function PublicDashboardScenePage({ route }: Props) {
};
}, [stateManager, accessToken, route.routeName]);
if (loadError) {
return <PublicDashboardScenePageError error={loadError} />;
}
if (!dashboard) {
return (
<Page layout={PageLayoutType.Custom} className={styles.loadingPage} data-testid={selectors.loadingPage}>
{isLoading && <PageLoader />}
{loadError && <h2>{loadError}</h2>}
</Page>
);
}
if (dashboard.state.meta.publicDashboardEnabled === false) {
return <PublicDashboardNotAvailable paused />;
}
if (dashboard.state.meta.dashboardNotFound) {
return <PublicDashboardNotAvailable />;
}
// if no time picker render without url sync
if (dashboard.state.controls?.state.hideTimeControls) {
return <PublicDashboardSceneRenderer model={dashboard} />;
@ -162,3 +158,35 @@ function getStyles(theme: GrafanaTheme2) {
}),
};
}
function PublicDashboardScenePageError({ error }: { error: LoadError }) {
const styles = useStyles2(getStyles);
const statusCode = error.status;
const messageId = error.messageId;
const message = error.message;
const isPublicDashboardPaused = statusCode === 403 && messageId === 'publicdashboards.notEnabled';
const isPublicDashboardNotFound = statusCode === 404 && messageId === 'publicdashboards.notFound';
const isDashboardNotFound = statusCode === 404 && messageId === 'publicdashboards.dashboardNotFound';
const publicDashboardEnabled = isPublicDashboardNotFound ? undefined : !isPublicDashboardPaused;
const dashboardNotFound = isPublicDashboardNotFound || isDashboardNotFound;
if (publicDashboardEnabled === false) {
return <PublicDashboardNotAvailable paused />;
}
if (dashboardNotFound) {
return <PublicDashboardNotAvailable />;
}
return (
<Page layout={PageLayoutType.Custom} className={styles.loadingPage} data-testid={selectors.loadingPage}>
<Box paddingY={4} display="flex" direction="column" alignItems="center">
<Alert severity={AppNotificationSeverity.Error} title={message}>
{message}
</Alert>
</Box>
</Page>
);
}

@ -421,10 +421,6 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
const { meta, viewPanelScene, editPanel, title, uid } = this.state;
const isNew = !Boolean(uid);
if (meta.dashboardNotFound) {
return { text: 'Not found' };
}
let pageNav: NavModelItem = {
text: title,
url: getDashboardUrl({

@ -2,7 +2,6 @@ import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors } from '@grafana/e2e-selectors';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
@ -34,46 +33,6 @@ jest.mock('@grafana/runtime', () => ({
}));
describe('DashboardSceneRenderer', () => {
it('should render Not Found notice when dashboard is not found', async () => {
const scene = transformSaveModelToScene({
meta: {
isSnapshot: true,
dashboardNotFound: true,
canStar: false,
canDelete: false,
canSave: false,
canEdit: false,
canShare: false,
},
dashboard: {
title: 'Not found',
uid: 'uid',
schemaVersion: 0,
// Disabling build in annotations to avoid mocking Grafana data source
annotations: {
list: [
{
builtIn: 1,
datasource: {
type: 'grafana',
uid: '-- Grafana --',
},
enable: false,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotations & Alerts',
type: 'dashboard',
},
],
},
},
});
render(<scene.Component model={scene} />);
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
});
it('should render angular deprecation notice when dashboard contains angular components', async () => {
const noticeText = /This dashboard depends on Angular/i;
//enable feature flag angularDeprecationUI

@ -4,7 +4,6 @@ import { useLocation, useParams } from 'react-router-dom-v5-compat';
import { PageLayoutType } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { getNavModel } from 'app/core/selectors/navModel';
import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty';
import { useSelector } from 'app/types';
@ -16,18 +15,8 @@ import { PanelSearchLayout } from './PanelSearchLayout';
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const {
controls,
overlay,
editview,
editPanel,
isEmpty,
meta,
viewPanelScene,
panelSearch,
panelsPerRow,
isEditing,
} = model.useState();
const { controls, overlay, editview, editPanel, isEmpty, viewPanelScene, panelSearch, panelsPerRow, isEditing } =
model.useState();
const { type } = useParams();
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
@ -60,10 +49,6 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
}
function renderBody() {
if (meta.dashboardNotFound) {
return <EntityNotFound entity="Dashboard" key="dashboard-not-found" />;
}
if (panelSearch || panelsPerRow) {
return <PanelSearchLayout panelSearch={panelSearch} panelsPerRow={panelsPerRow} dashboard={model} />;
}

@ -178,14 +178,6 @@ describe('NavToolbarActions', () => {
expect(screen.queryByTestId('button-snapshot')).toBeInTheDocument();
});
it('should not show link button when is not found dashboard', () => {
setup({
isSnapshot: true,
dashboardNotFound: true,
});
expect(screen.queryByTestId('button-snapshot')).not.toBeInTheDocument();
});
});
});

@ -70,7 +70,6 @@ export function ToolbarActions({ dashboard }: Props) {
const isViewingPanel = Boolean(viewPanelScene);
const isEditedPanelDirty = usePanelEditDirty(editPanel);
const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve());
const isNotFound = Boolean(meta.dashboardNotFound);
const isNew = !Boolean(uid);
const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY);
@ -80,10 +79,6 @@ export function ToolbarActions({ dashboard }: Props) {
const showScopesSelector = config.featureToggles.scopeFilters && !isEditing;
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
if (isNotFound) {
return null;
}
if (!isEditingPanel) {
// This adds the precence indicators in enterprise
addDynamicActions(toolbarActions, dynamicDashNavActions.left, 'left-actions');
@ -150,7 +145,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'icon-actions',
condition: meta.isSnapshot && !meta.dashboardNotFound && !isEditing,
condition: meta.isSnapshot && !isEditing,
render: () => (
<GoToSnapshotOriginButton key="go-to-snapshot-origin" originalURL={dashboard.getSnapshotUrl() ?? ''} />
),

@ -55,7 +55,6 @@ import {
import { contextSrv } from 'app/core/core';
import {
AnnoKeyCreatedBy,
AnnoKeyDashboardNotFound,
AnnoKeyFolder,
AnnoKeyUpdatedBy,
AnnoKeyUpdatedTimestamp,
@ -148,7 +147,6 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
showSettings: Boolean(dto.access.canEdit),
canMakeEditable: canSave && !isDashboardEditable,
hasUnsavedFolderChange: false,
dashboardNotFound: Boolean(dto.metadata.annotations?.[AnnoKeyDashboardNotFound]),
version: parseInt(metadata.resourceVersion, 10),
k8s: metadata,
};

@ -5,9 +5,10 @@ import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2 } from '@grafana/data';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Alert, Spinner, useStyles2 } from '@grafana/ui';
import { Alert, Box, Spinner, useStyles2 } from '@grafana/ui';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { t } from 'app/core/internationalization';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DashboardPageRouteParams } from 'app/features/dashboard/containers/types';
import { DashboardRoutes } from 'app/types';
@ -24,7 +25,7 @@ export interface Props extends GrafanaRouteComponentProps<DashboardPageRoutePara
*/
export function SoloPanelPage({ queryParams }: Props) {
const stateManager = getDashboardScenePageStateManager();
const { dashboard } = stateManager.useState();
const { dashboard, loadError } = stateManager.useState();
const { uid = '' } = useParams();
useEffect(() => {
@ -36,6 +37,16 @@ export function SoloPanelPage({ queryParams }: Props) {
return <EntityNotFound entity="Panel" />;
}
if (loadError) {
return (
<Box justifyContent={'center'} alignItems={'center'} display={'flex'} height={'100%'}>
<Alert severity="error" title={t('dashboard.errors.failed-to-load', 'Failed to load dashboard')}>
{loadError.message}
</Alert>
</Box>
);
}
if (!dashboard) {
return <PageLoader />;
}

@ -1,4 +1,5 @@
import { VariableRefresh } from '@grafana/data';
import { FetchError } from '@grafana/runtime';
import {
DeepPartial,
EmbeddedScene,
@ -31,6 +32,25 @@ export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jes
} as unknown as DashboardLoaderSrv);
return loadDashboardMock;
}
export function setupLoadDashboardMockReject(rsp: DeepPartial<FetchError>, spy?: jest.Mock) {
const loadDashboardMock = (spy || jest.fn()).mockRejectedValue(rsp);
// disabling type checks since this is a test util
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
setDashboardLoaderSrv({
loadDashboard: loadDashboardMock,
} as unknown as DashboardLoaderSrv);
return loadDashboardMock;
}
export function setupLoadDashboardRuntimeErrorMock() {
// disabling type checks since this is a test util
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
setDashboardLoaderSrv({
loadDashboard: () => {
throw new Error('Runtime error');
},
} as unknown as DashboardLoaderSrv);
}
export function mockResizeObserver() {
window.ResizeObserver = class ResizeObserver {

@ -40,7 +40,12 @@ jest.mock('app/features/live/dashboard/dashboardWatcher', () => ({
describe('Legacy dashboard API', () => {
it('should throw an error if requesting a folder', async () => {
const api = new LegacyDashboardAPI();
expect(async () => await api.getDashboardDTO('folderUid')).rejects.toThrowError('Dashboard not found');
await expect(api.getDashboardDTO('folderUid')).rejects.toMatchObject({
status: 404,
config: { url: `/api/dashboards/uid/folderUid` },
data: { message: 'Dashboard not found' },
});
});
it('should return a valid dashboard', async () => {

@ -1,5 +1,5 @@
import { AppEvents, UrlQueryMap } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { FetchError, getBackendSrv } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
@ -33,7 +33,12 @@ export class LegacyDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard>
if (result.meta.isFolder) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
throw new Error('Dashboard not found');
const fetchError: FetchError = {
status: 404,
config: { url: `/api/dashboards/uid/${uid}` },
data: { message: 'Dashboard not found' },
};
throw fetchError;
}
return result;

@ -133,6 +133,13 @@ describe('v0 dashboard API', () => {
expect(result.meta.folderUid).toBe('new-folder');
});
it('throws an error if folder is not found', async () => {
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValue({ message: 'folder not found', status: 'not-found' });
const api = new K8sDashboardAPI();
await expect(api.getDashboardDTO('test')).rejects.toThrow('Failed to load folder');
});
describe('saveDashboard', () => {
beforeEach(() => {
locationUtil.initialize({

@ -1,6 +1,7 @@
import { locationUtil } from '@grafana/data';
import { Dashboard } from '@grafana/schema';
import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import kbn from 'app/core/utils/kbn';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
@ -91,32 +92,46 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
}
async getDashboardDTO(uid: string) {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
const result: DashboardDTO = {
meta: {
...dash.access,
isNew: false,
isFolder: false,
uid: dash.metadata.name,
k8s: dash.metadata,
version: parseInt(dash.metadata.resourceVersion, 10),
},
dashboard: dash.spec,
};
try {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
const result: DashboardDTO = {
meta: {
...dash.access,
isNew: false,
isFolder: false,
uid: dash.metadata.name,
k8s: dash.metadata,
version: parseInt(dash.metadata.resourceVersion, 10),
},
dashboard: dash.spec,
};
if (dash.metadata.annotations?.[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]);
result.meta.folderTitle = folder.title;
result.meta.folderUrl = folder.url;
result.meta.folderUid = folder.uid;
result.meta.folderId = folder.id;
} catch (e) {
console.error('Failed to load a folder', e);
if (dash.metadata.annotations?.[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]);
result.meta.folderTitle = folder.title;
result.meta.folderUrl = folder.url;
result.meta.folderUid = folder.uid;
result.meta.folderId = folder.id;
} catch (e) {
throw new Error('Failed to load folder');
}
}
return result;
} catch (e) {
const status = getStatusFromError(e);
const message = getMessageFromError(e);
// Hacking around a bug in k8s api server that returns 500 for not found resources
if (message.includes('not found') && status !== 404) {
// @ts-expect-error
e.status = 404;
// @ts-expect-error
e.data.message = 'Dashboard not found';
}
}
return result;
throw e;
}
}
}

@ -75,8 +75,7 @@ describe('v2 dashboard API', () => {
updatedBy: '',
});
const convertToV1 = false;
const api = new K8sDashboardV2API(convertToV1);
const api = new K8sDashboardV2API(false);
// because the API can currently return both DashboardDTO and DashboardWithAccessInfo<DashboardV2Spec> based on the
// parameter convertToV1, we need to cast the result to DashboardWithAccessInfo<DashboardV2Spec> to be able to
// access
@ -86,6 +85,13 @@ describe('v2 dashboard API', () => {
expect(result.metadata.annotations![AnnoKeyFolderUrl]).toBe('/folder/url');
expect(result.metadata.annotations![AnnoKeyFolder]).toBe('new-folder');
});
it('throws an error if folder is not found', async () => {
jest.spyOn(backendSrv, 'getFolderByUid').mockRejectedValue({ message: 'folder not found', status: 'not-found' });
const api = new K8sDashboardV2API(false);
await expect(api.getDashboardDTO('test')).rejects.toThrow('Failed to load folder');
});
});
describe('v2 dashboard API - Save', () => {

@ -1,6 +1,7 @@
import { locationUtil, UrlQueryMap } from '@grafana/data';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import kbn from 'app/core/utils/kbn';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
@ -37,33 +38,47 @@ export class K8sDashboardV2API
}
async getDashboardDTO(uid: string, params?: UrlQueryMap) {
const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto');
let result: DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO | undefined;
// TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses.
result = ResponseTransformers.ensureV2Response(dashboard);
// load folder info if available
if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]);
result.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
result.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
result.metadata.annotations[AnnoKeyFolderId] = folder.id;
} catch (e) {
console.error('Failed to load a folder', e);
try {
const dashboard = await this.client.subresource<DashboardWithAccessInfo<DashboardV2Spec>>(uid, 'dto');
let result: DashboardWithAccessInfo<DashboardV2Spec> | DashboardDTO | undefined;
// TODO: For dev purposes only, the conversion should and will happen in the API. This is just to stub v2 api responses.
result = ResponseTransformers.ensureV2Response(dashboard);
// load folder info if available
if (result.metadata.annotations && result.metadata.annotations[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(result.metadata.annotations[AnnoKeyFolder]);
result.metadata.annotations[AnnoKeyFolderTitle] = folder.title;
result.metadata.annotations[AnnoKeyFolderUrl] = folder.url;
result.metadata.annotations[AnnoKeyFolderId] = folder.id;
} catch (e) {
throw new Error('Failed to load folder');
}
}
}
// Depending on the ui components readiness, we might need to convert the response to v1
if (this.convertToV1) {
// Always return V1 format
result = ResponseTransformers.ensureV1Response(result);
// Depending on the ui components readiness, we might need to convert the response to v1
if (this.convertToV1) {
// Always return V1 format
result = ResponseTransformers.ensureV1Response(result);
return result;
}
// return the v2 response
return result;
} catch (e) {
const status = getStatusFromError(e);
const message = getMessageFromError(e);
// Hacking around a bug in k8s api server that returns 500 for not found resources
if (message.includes('not found') && status !== 404) {
// @ts-expect-error
e.status = 404;
// @ts-expect-error
e.data.message = 'Dashboard not found';
}
throw e;
}
// return the v2 response
return result;
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {

@ -9,7 +9,6 @@ import { Themeable2, withTheme2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
import { Page } from 'app/core/components/Page/Page';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getKioskMode } from 'app/core/navigation/kiosk';
@ -26,7 +25,6 @@ import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
import { cancelVariables, templateVarsChangedInUrl } from '../../variables/state/actions';
import { findTemplateVarChanges } from '../../variables/utils';
import { DashNav } from '../components/DashNav';
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading';
import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
import { DashboardSettings } from '../components/DashboardSettings';
@ -41,6 +39,7 @@ import { explicitlyControlledMigrationPanels, autoMigrateAngular } from '../stat
import { cleanUpDashboardAndVariables } from '../state/actions';
import { initDashboard } from '../state/initDashboard';
import { DashboardPageError } from './DashboardPageError';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types';
import 'react-grid-layout/css/styles.css';
@ -355,7 +354,8 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
};
render() {
const { dashboard, initError, queryParams, theme } = this.props;
const { dashboard, initError, queryParams, theme, params } = this.props;
const { editPanel, viewPanel, pageNav, sectionNav } = this.state;
const kioskMode = getKioskMode(this.props.queryParams);
const styles = getStyles(theme);
@ -367,21 +367,13 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
const inspectPanel = this.getInspectPanel();
const showSubMenu = !editPanel && !kioskMode && !this.props.queryParams.editview && dashboard.isSubMenuVisible();
const showToolbar = kioskMode !== KioskMode.Full && !queryParams.editview;
const showToolbar = kioskMode !== KioskMode.Full && !queryParams.editview && !initError;
const pageClassName = cx({
[styles.fullScreenPanel]: Boolean(viewPanel),
'page-hidden': Boolean(queryParams.editview || editPanel),
});
if (dashboard.meta.dashboardNotFound) {
return (
<Page navId="dashboards/browse" layout={PageLayoutType.Canvas} pageNav={{ text: 'Not found' }}>
<EntityNotFound entity="Dashboard" />
</Page>
);
}
const migrationFeatureFlags = new Set([
'autoMigrateOldPanels',
'autoMigrateGraphPanel',
@ -448,7 +440,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
</header>
)}
<DashboardPrompt dashboard={dashboard} />
{initError && <DashboardFailed />}
{initError && <DashboardPageError error={initError.error} type={params.type} />}
{showSubMenu && (
<section aria-label={selectors.pages.Dashboard.SubMenu.submenu}>
<SubMenu dashboard={dashboard} annotations={dashboard.annotations.list} links={dashboard.links} />
@ -463,12 +455,14 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
/>
)}
{showDashboardMigrationNotice && <AngularMigrationNotice dashboardUid={dashboard.uid} />}
<DashboardGrid
dashboard={dashboard}
isEditable={!!dashboard.meta.canEdit}
viewPanel={viewPanel}
editPanel={editPanel}
/>
{!initError && (
<DashboardGrid
dashboard={dashboard}
isEditable={!!dashboard.meta.canEdit}
viewPanel={viewPanel}
editPanel={editPanel}
/>
)}
{inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} />}
{queryParams.shareView && (

@ -0,0 +1,30 @@
import { PageLayoutType } from '@grafana/data';
import { Alert, Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { t } from 'app/core/internationalization';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
export function DashboardPageError({ error, type }: { error: unknown; type?: string }) {
const status = getStatusFromError(error);
const message = getMessageFromError(error);
const entity = type === 'snapshot' ? 'Snapshot' : 'Dashboard';
return (
<Page navId="dashboards/browse" layout={PageLayoutType.Canvas} pageNav={{ text: 'Not found' }}>
<Box paddingY={4} display="flex" direction="column" alignItems="center">
{status === 404 ? (
<EntityNotFound entity={entity} />
) : (
<Alert
title={t('dashboard.errors.failed-to-load', 'Failed to load dashboard')}
severity="error"
data-testid="dashboard-page-error"
>
{message}
</Alert>
)}
</Box>
</Page>
);
}

@ -3,13 +3,20 @@ import { useParams } from 'react-router-dom-v5-compat';
import { Props } from 'react-virtualized-auto-sizer';
import { render } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService } from '@grafana/runtime';
import {
HOME_DASHBOARD_CACHE_KEY,
getDashboardScenePageStateManager,
} from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import {
setupLoadDashboardMockReject,
setupLoadDashboardRuntimeErrorMock,
} from 'app/features/dashboard-scene/utils/test-utils';
import { DashboardDTO, DashboardRoutes } from 'app/types';
import { DashboardLoaderSrv, setDashboardLoaderSrv } from '../services/DashboardLoaderSrv';
import DashboardPageProxy, { DashboardPageProxyProps } from './DashboardPageProxy';
const dashMock: DashboardDTO = {
@ -63,6 +70,11 @@ jest.mock('@grafana/runtime', () => ({
get: jest.fn().mockResolvedValue({}),
}),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => {
return {
get: jest.fn().mockResolvedValue({ dashboard: {}, meta: { url: '' } }),
};
},
}));
jest.mock('react-virtualized-auto-sizer', () => {
@ -75,11 +87,11 @@ jest.mock('react-virtualized-auto-sizer', () => {
});
});
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: () => ({
getDashboardDTO: jest.fn().mockResolvedValue(dashMock),
}),
}));
setDashboardLoaderSrv({
loadDashboard: jest.fn().mockResolvedValue(dashMock),
// disabling type checks since this is a test util
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
} as unknown as DashboardLoaderSrv);
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
@ -209,4 +221,63 @@ describe('DashboardPageProxy', () => {
});
});
});
describe('errors rendering', () => {
it('should render dashboard not found notice when dashboard... not found', async () => {
setupLoadDashboardMockReject({
status: 404,
statusText: 'Not Found',
data: {
message: 'Dashboard not found',
},
config: {
method: 'GET',
url: 'api/dashboards/uid/adfjq9edwm0hsdsa',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
},
hideFromInspector: true,
},
isHandled: true,
});
setup({ route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' }, uid: 'abc' });
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
});
it('should render error alert for backend errors', async () => {
setupLoadDashboardMockReject({
status: 500,
statusText: 'internal server error',
data: {
message: 'Internal server error',
},
config: {
method: 'GET',
url: 'api/dashboards/uid/adfjq9edwm0hsdsa',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
},
hideFromInspector: true,
},
isHandled: true,
});
setup({ route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' }, uid: 'abc' });
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Internal server error');
});
it('should render error alert for runtime errors', async () => {
setupLoadDashboardRuntimeErrorMock();
setup({ route: { routeName: DashboardRoutes.Normal, component: () => null, path: '/' }, uid: 'abc' });
expect(await screen.findByTestId('dashboard-page-error')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-page-error')).toHaveTextContent('Runtime error');
});
});
});

@ -8,6 +8,7 @@ import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/
import { DashboardRoutes } from 'app/types';
import DashboardPage, { DashboardPageParams } from './DashboardPage';
import { DashboardPageError } from './DashboardPageError';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './types';
export type DashboardPageProxyProps = Omit<
@ -46,9 +47,17 @@ function DashboardPageProxy(props: DashboardPageProxyProps) {
return null;
}
return stateManager.fetchDashboard({ route: props.route.routeName as DashboardRoutes, uid: params.uid ?? '' });
return stateManager.fetchDashboard({
route: props.route.routeName as DashboardRoutes,
uid: params.uid ?? '',
type: params.type,
});
}, [params.uid, props.route.routeName]);
if (dashboard.error) {
return <DashboardPageError error={dashboard.error} />;
}
if (dashboard.loading) {
return null;
}

@ -260,7 +260,29 @@ describe('PublicDashboardPage', () => {
setup(undefined, {
dashboard: {
...dashboardBase,
getModel: () => getTestDashboard(undefined, { publicDashboardEnabled: false, dashboardNotFound: false }),
initError: {
message: 'Failed to fetch dashboard',
error: {
status: 403,
statusText: 'Forbidden',
data: {
statusCode: 403,
messageId: 'publicdashboards.notEnabled',
message: 'Dashboard paused',
},
config: {
method: 'GET',
url: 'api/public/dashboards/4615c835a4e441f09c94fb1b073e6d2e',
retry: 0,
headers: {
'X-Grafana-Org-Id': 1,
'X-Grafana-Device-Id': 'da48fad0e58ba327fd7d1e6bd17e9c63',
},
hideFromInspector: true,
},
},
},
getModel: () => getTestDashboard(undefined, { publicDashboardEnabled: false }),
},
});
@ -277,7 +299,28 @@ describe('PublicDashboardPage', () => {
setup(undefined, {
dashboard: {
...dashboardBase,
getModel: () => getTestDashboard(undefined, { dashboardNotFound: true }),
initError: {
message: 'Failed to fetch dashboard',
error: {
status: 404,
statusText: 'Not Found',
data: {
statusCode: 404,
messageId: 'publicdashboards.notFound',
message: 'Dashboard not found',
},
config: {
method: 'GET',
url: 'api/public/dashboards/ce159fe139fc4d238a7d9c3ae33fb82b',
retry: 0,
hideFromInspector: true,
headers: {
'X-Grafana-Device-Id': 'da48fad0e58ba327fd7d1e6bd17e9c63',
},
},
},
},
getModel: () => getTestDashboard(undefined, {}),
},
});

@ -14,7 +14,7 @@ import {
PublicDashboardPageRouteSearchParams,
} from 'app/features/dashboard/containers/types';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
import { useSelector, useDispatch } from 'app/types';
import { useSelector, useDispatch, DashboardInitError } from 'app/types';
import { DashNavTimeControls } from '../components/DashNav/DashNavTimeControls';
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
@ -64,6 +64,7 @@ const PublicDashboardPage = (props: Props) => {
const prevProps = usePrevious({ ...props, location });
const styles = useStyles2(getStyles);
const dashboardState = useSelector((store) => store.dashboard);
const loadError = dashboardState.initError;
const dashboard = dashboardState.getModel();
useEffect(() => {
@ -96,16 +97,12 @@ const PublicDashboardPage = (props: Props) => {
}
}, [prevProps, location.search, props.queryParams, dashboard?.timepicker.hidden, accessToken]);
if (!dashboard) {
return <DashboardLoading initPhase={dashboardState.initPhase} />;
}
if (dashboard.meta.publicDashboardEnabled === false) {
return <PublicDashboardNotAvailable paused />;
if (loadError) {
return <PublicDashboardPageError error={loadError} />;
}
if (dashboard.meta.dashboardNotFound) {
return <PublicDashboardNotAvailable />;
if (!dashboard) {
return <DashboardLoading initPhase={dashboardState.initPhase} />;
}
return (
@ -134,3 +131,31 @@ const getStyles = (theme: GrafanaTheme2) => ({
});
export default PublicDashboardPage;
function PublicDashboardPageError({ error }: { error: DashboardInitError }) {
let statusCode: number | undefined;
let messageId: string | undefined;
if (typeof error.error === 'object' && error.error !== null && 'data' in error.error) {
const typedError = error.error as { data: { statusCode: number; messageId: string } };
statusCode = typedError.data.statusCode;
messageId = typedError.data.messageId;
}
const isPublicDashboardPaused = statusCode === 403 && messageId === 'publicdashboards.notEnabled';
const isPublicDashboardNotFound = statusCode === 404 && messageId === 'publicdashboards.notFound';
const isDashboardNotFound = statusCode === 404 && messageId === 'publicdashboards.dashboardNotFound';
const publicDashboardEnabled = isPublicDashboardNotFound ? undefined : !isPublicDashboardPaused;
const dashboardNotFound = isPublicDashboardNotFound || isDashboardNotFound;
if (publicDashboardEnabled === false) {
return <PublicDashboardNotAvailable paused />;
}
if (dashboardNotFound) {
return <PublicDashboardNotAvailable />;
}
return <DashboardFailed initError={error} />;
}

@ -4,12 +4,10 @@ import moment from 'moment'; // eslint-disable-line no-restricted-imports
import { AppEvents, dateMath, UrlQueryMap, UrlQueryValue } from '@grafana/data';
import { getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { DashboardV2Spec, defaultDashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { backendSrv } from 'app/core/services/backend_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { getMessageFromError } from 'app/core/utils/errors';
import kbn from 'app/core/utils/kbn';
import { AnnoKeyDashboardIsSnapshot, AnnoKeyDashboardNotFound } from 'app/features/apiserver/types';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { DashboardDTO } from 'app/types';
@ -23,7 +21,6 @@ import { getDashboardSrv } from './DashboardSrv';
import { getDashboardSnapshotSrv } from './SnapshotSrv';
interface DashboardLoaderSrvLike<T> {
_dashboardLoadFailed(title: string, snapshot?: boolean): T;
loadDashboard(
type: UrlQueryValue,
slug: string | undefined,
@ -33,7 +30,6 @@ interface DashboardLoaderSrvLike<T> {
}
abstract class DashboardLoaderSrvBase<T> implements DashboardLoaderSrvLike<T> {
abstract _dashboardLoadFailed(title: string, snapshot?: boolean): T;
abstract loadDashboard(
type: UrlQueryValue,
slug: string | undefined,
@ -66,7 +62,7 @@ abstract class DashboardLoaderSrvBase<T> implements DashboardLoaderSrvLike<T> {
'Script Error',
'Please make sure it exists and returns a valid dashboard',
]);
return this._dashboardLoadFailed('Scripted dashboard');
throw err;
}
);
}
@ -116,22 +112,6 @@ abstract class DashboardLoaderSrvBase<T> implements DashboardLoaderSrvLike<T> {
}
export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
_dashboardLoadFailed(title: string, snapshot?: boolean) {
snapshot = snapshot || false;
return {
meta: {
canStar: false,
isSnapshot: snapshot,
canDelete: false,
canSave: false,
canEdit: false,
canShare: false,
dashboardNotFound: true,
},
dashboard: { title, uid: title, schemaVersion: 0 },
};
}
loadDashboard(
type: UrlQueryValue,
slug: string | undefined,
@ -146,38 +126,11 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
// needed for the old architecture
// in scenes this is handled through loadSnapshot method
} else if (type === 'snapshot' && slug) {
promise = getDashboardSnapshotSrv()
.getSnapshot(slug)
.catch(() => {
return this._dashboardLoadFailed('Snapshot not found', true);
});
promise = getDashboardSnapshotSrv().getSnapshot(slug);
} else if (type === 'public' && uid) {
promise = backendSrv
.getPublicDashboardByUid(uid)
.then((result) => {
return result;
})
.catch((e) => {
const isPublicDashboardPaused =
e.data.statusCode === 403 && e.data.messageId === 'publicdashboards.notEnabled';
const isPublicDashboardNotFound =
e.data.statusCode === 404 && e.data.messageId === 'publicdashboards.notFound';
const isDashboardNotFound =
e.data.statusCode === 404 && e.data.messageId === 'publicdashboards.dashboardNotFound';
const dashboardModel = this._dashboardLoadFailed(
isPublicDashboardPaused ? 'Public Dashboard paused' : 'Public Dashboard Not found',
true
);
return {
...dashboardModel,
meta: {
...dashboardModel.meta,
publicDashboardEnabled: isPublicDashboardNotFound ? undefined : !isPublicDashboardPaused,
dashboardNotFound: isPublicDashboardNotFound || isDashboardNotFound,
},
};
});
promise = backendSrv.getPublicDashboardByUid(uid).then((result) => {
return result;
});
} else if (uid) {
if (!params) {
const cachedDashboard = stateManager.getDashboardFromCache(uid);
@ -188,26 +141,23 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
promise = getDashboardAPI()
.getDashboardDTO(uid, params)
.then((result) => {
if (result.meta.isFolder) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
throw new Error('Dashboard not found');
.catch((e) => {
console.error('Failed to load dashboard', e);
if (isFetchError(e)) {
e.isHandled = true;
if (e.status === 404) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
}
}
return result;
})
.catch(() => {
const dash = this._dashboardLoadFailed('Not found', true);
dash.dashboard.uid = '';
return dash;
throw e;
});
} else {
throw new Error('Dashboard uid or slug required');
}
promise.then((result: DashboardDTO) => {
if (result.meta.dashboardNotFound !== true) {
impressionSrv.addDashboardImpression(result.dashboard.uid);
}
impressionSrv.addDashboardImpression(result.dashboard.uid);
return result;
});
@ -216,16 +166,10 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
}
loadSnapshot(slug: string): Promise<DashboardDTO> {
const promise = getDashboardSnapshotSrv()
.getSnapshot(slug)
.catch(() => {
return this._dashboardLoadFailed('Snapshot not found', true);
});
const promise = getDashboardSnapshotSrv().getSnapshot(slug);
promise.then((result: DashboardDTO) => {
if (result.meta.dashboardNotFound !== true) {
impressionSrv.addDashboardImpression(result.dashboard.uid);
}
impressionSrv.addDashboardImpression(result.dashboard.uid);
return result;
});
@ -235,36 +179,6 @@ export class DashboardLoaderSrv extends DashboardLoaderSrvBase<DashboardDTO> {
}
export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAccessInfo<DashboardV2Spec>> {
_dashboardLoadFailed(title: string, snapshot?: boolean) {
const dashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title,
},
access: {
canSave: false,
canEdit: false,
canAdmin: false,
canStar: false,
canShare: false,
canDelete: false,
},
apiVersion: 'v2alpha1',
metadata: {
creationTimestamp: '',
name: title,
namespace: '',
resourceVersion: '',
annotations: {
[AnnoKeyDashboardNotFound]: true,
[AnnoKeyDashboardIsSnapshot]: Boolean(snapshot),
},
},
};
return dashboard;
}
loadDashboard(
type: UrlQueryValue,
slug: string | undefined,
@ -277,34 +191,9 @@ export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAc
if (type === 'script' && slug) {
promise = this.loadScriptedDashboard(slug).then((r) => ResponseTransformers.ensureV2Response(r));
} else if (type === 'public' && uid) {
promise = backendSrv
.getPublicDashboardByUid(uid)
.then((result) => {
return ResponseTransformers.ensureV2Response(result);
})
.catch((e) => {
const isPublicDashboardPaused =
e.data.statusCode === 403 && e.data.messageId === 'publicdashboards.notEnabled';
// const isPublicDashboardNotFound =
// e.data.statusCode === 404 && e.data.messageId === 'publicdashboards.notFound';
// const isDashboardNotFound =
// e.data.statusCode === 404 && e.data.messageId === 'publicdashboards.dashboardNotFound';
const dashboardModel = this._dashboardLoadFailed(
isPublicDashboardPaused ? 'Public Dashboard paused' : 'Public Dashboard Not found',
true
);
return dashboardModel;
// TODO[schema v2]:
// return {
// ...dashboardModel,
// meta: {
// ...dashboardModel.meta,
// publicDashboardEnabled: isPublicDashboardNotFound ? undefined : !isPublicDashboardPaused,
// dashboardNotFound: isPublicDashboardNotFound || isDashboardNotFound,
// },
// };
});
promise = backendSrv.getPublicDashboardByUid(uid).then((result) => {
return ResponseTransformers.ensureV2Response(result);
});
} else if (uid) {
if (!params) {
const cachedDashboard = stateManager.getDashboardFromCache(uid);
@ -319,21 +208,19 @@ export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAc
console.error('Failed to load dashboard', e);
if (isFetchError(e)) {
e.isHandled = true;
if (e.status === 404) {
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
}
}
appEvents.emit(AppEvents.alertError, ['Dashboard not found']);
const dash = this._dashboardLoadFailed('Not found', true);
return dash;
throw e;
});
} else {
throw new Error('Dashboard uid or slug required');
}
promise.then((result: DashboardWithAccessInfo<DashboardV2Spec>) => {
if (result.metadata.annotations?.[AnnoKeyDashboardNotFound] !== true) {
impressionSrv.addDashboardImpression(result.metadata.name);
}
impressionSrv.addDashboardImpression(result.metadata.name);
return result;
});
@ -343,16 +230,10 @@ export class DashboardLoaderSrvV2 extends DashboardLoaderSrvBase<DashboardWithAc
loadSnapshot(slug: string): Promise<DashboardWithAccessInfo<DashboardV2Spec>> {
const promise = getDashboardSnapshotSrv()
.getSnapshot(slug)
.then((r) => ResponseTransformers.ensureV2Response(r))
.catch((e) => {
const msg = getMessageFromError(e);
throw new Error(`Failed to load snapshot: ${msg}`);
});
.then((r) => ResponseTransformers.ensureV2Response(r));
promise.then((result: DashboardWithAccessInfo<DashboardV2Spec>) => {
if (result.metadata.annotations?.[AnnoKeyDashboardNotFound] !== true) {
impressionSrv.addDashboardImpression(result.metadata.name);
}
impressionSrv.addDashboardImpression(result.metadata.name);
return result;
});

@ -47,9 +47,13 @@ const legacyDashboardSnapshotSrv: DashboardSnapshotSrv = {
getSharingOptions: () => getBackendSrv().get<SnapshotSharingOptions>('/api/snapshot/shared-options'),
deleteSnapshot: (key: string) => getBackendSrv().delete('/api/snapshots/' + key),
getSnapshot: async (key: string) => {
const dto = await getBackendSrv().get<DashboardDTO>('/api/snapshots/' + key);
dto.meta.canShare = false;
return dto;
try {
const dto = await getBackendSrv().get<DashboardDTO>('/api/snapshots/' + key);
dto.meta.canShare = false;
return dto;
} catch (e) {
throw e;
}
},
};

@ -66,7 +66,6 @@ export interface DashboardMeta {
hasUnsavedFolderChange?: boolean;
annotationsPermissions?: AnnotationsPermissions;
publicDashboardEnabled?: boolean;
dashboardNotFound?: boolean;
isEmbedded?: boolean;
isNew?: boolean;
version?: number;

@ -893,6 +893,9 @@
"import-a-dashboard-header": "Import a dashboard",
"import-dashboard-button": "Import dashboard"
},
"errors": {
"failed-to-load": "Failed to load dashboard"
},
"inspect": {
"data-tab": "Data",
"error-tab": "Error",

@ -893,6 +893,9 @@
"import-a-dashboard-header": "Ĩmpőřŧ ä đäşĥþőäřđ",
"import-dashboard-button": "Ĩmpőřŧ đäşĥþőäřđ"
},
"errors": {
"failed-to-load": "Fäįľęđ ŧő ľőäđ đäşĥþőäřđ"
},
"inspect": {
"data-tab": "Đäŧä",
"error-tab": "Ēřřőř",

Loading…
Cancel
Save