mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 06:42:21 +08:00
PublicDashboards: Frontend routing for public dashboards (#48834)
* add isPublic to dashboard * refactor routes to use route group and add placeholder method for sharing apii * add sharing pane and utils for public dashboard config to sharing modal * Sharing modal now persists data through the api * moves ShareDashboard endpoint to new file and starts adding tests * generates mocks. Adds tests for public dashboard feature flag * Adds ability to pass in array of features to enable for the test * test to update public flag on dashboard WIP * Adds mock for SaveDashboardSharingConfig * Fixes tests. Had to use FakeDashboardService * Adds React tests for public dashboards toggle * removes semicolons * refactors SharePublic component to use hooks * rename from `share publicly` to `public dashboard config` * checkpoint. debugging tests. need to verify name changes * checkpoint. test bugs fixed. need to finish returning proper response codes * finish renaming. fix test * Update pkg/api/api.go Co-authored-by: Torkel Ödegaard <torkel@grafana.com> * update backend url * rename internal objects and commands. fix configuration modal labels * add endpoint for retrieving public dashboard configuration and populate the frontend state from it * add test for dashboardCanBePublic * adds backend routes * copy DashboardPage component into component for public dashboards. WIP * adds react routes, and doesnt render main nav bar when viewing a public route * removes extra react route from testing * updates component name * Wrap the original dashboard component so we can pass props relevant to public dashboards, turn kiosk mode on/off, etc * Wraps DashboardPage in PublicDashboardPage component. DashboardPage gets rendered in kiosk mode when public prop is passed. * removes commented out code from exploratory work * Makes public dashboard routes require no auth * extracts helper to own util file to check if were viewing a public page * Hides panel dropdown when its being viewed publicly * formatting * use function from utils file for determining if publicly viewed. If public, hides app notifications, searchwrapper, and commandpalette. * adds unit tests for util function used to see if page is being viewed publicly * cant added annotations to panel when being publicly viewed * removes useless comment * hides backend and frontend pubdash routes behind feature flag * consider feature flag when checking url path to see if on public dashboard * renames function * still render app notifications when in public view * Extract pubdash route logic into own file * fixes failing tests * Determines path using location locationUtils. This covers the case when grafana is being hosted on a subpath. Updates tests. * renames pubdash web route to be more understandable * rename route * fixes failing test * fixes failing test. Needed to update pubdash urls * sets flag on grafana boot config for if viewing public dashboard. Removes hacky check that looks at the url * fixes failing tests. Uses config to determine if viewing public dashboard * renders the blue panel timeInfo on public dashboard panel * Extracts conditional logic for rendering components out into their own functions * removes publicDashboardView check, and uses dashboard meta instead * the timeInfo is always displayed on the panel * After fetch of public dashboard dto, the meta isPublic flag gets set and used to determine if viewing public dashboard for child components. Fixes tests for PanelHeader. * Fixes failing test. Needed to add isPublic flag to dashboard meta. Co-authored-by: Jeff Levin <jeff@levinology.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
@ -133,6 +133,7 @@ export interface BootData {
|
|||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export interface GrafanaConfig {
|
export interface GrafanaConfig {
|
||||||
|
isPublicDashboardView: boolean;
|
||||||
datasources: { [str: string]: DataSourceInstanceSettings };
|
datasources: { [str: string]: DataSourceInstanceSettings };
|
||||||
panels: { [key: string]: PanelPluginMeta };
|
panels: { [key: string]: PanelPluginMeta };
|
||||||
minRefreshInterval: string;
|
minRefreshInterval: string;
|
||||||
|
@ -24,6 +24,7 @@ export interface AzureSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GrafanaBootConfig implements GrafanaConfig {
|
export class GrafanaBootConfig implements GrafanaConfig {
|
||||||
|
isPublicDashboardView: boolean;
|
||||||
datasources: { [str: string]: DataSourceInstanceSettings } = {};
|
datasources: { [str: string]: DataSourceInstanceSettings } = {};
|
||||||
panels: { [key: string]: PanelPluginMeta } = {};
|
panels: { [key: string]: PanelPluginMeta } = {};
|
||||||
minRefreshInterval = '';
|
minRefreshInterval = '';
|
||||||
@ -124,6 +125,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
this.theme2 = createTheme({ colors: { mode } });
|
this.theme2 = createTheme({ colors: { mode } });
|
||||||
this.theme = this.theme2.v1;
|
this.theme = this.theme2.v1;
|
||||||
this.bootData = options.bootData;
|
this.bootData = options.bootData;
|
||||||
|
this.isPublicDashboardView = options.bootData.settings.isPublicDashboardView;
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
datasources: {},
|
datasources: {},
|
||||||
|
@ -89,6 +89,11 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/a/:id/*", reqSignedIn, hs.Index) // App Root Page
|
r.Get("/a/:id/*", reqSignedIn, hs.Index) // App Root Page
|
||||||
r.Get("/a/:id", reqSignedIn, hs.Index)
|
r.Get("/a/:id", reqSignedIn, hs.Index)
|
||||||
|
|
||||||
|
//pubdash
|
||||||
|
if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) {
|
||||||
|
r.Get("/public-dashboards/:uid", middleware.SetPublicDashboardFlag(), hs.Index)
|
||||||
|
}
|
||||||
|
|
||||||
r.Get("/d/:uid/:slug", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
r.Get("/d/:uid/:slug", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
||||||
r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
||||||
r.Get("/dashboard/script/*", reqSignedIn, hs.Index)
|
r.Get("/dashboard/script/*", reqSignedIn, hs.Index)
|
||||||
|
@ -717,6 +717,10 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.IsPublicDashboardView {
|
||||||
|
settings["isPublicDashboardView"] = true
|
||||||
|
}
|
||||||
|
|
||||||
data := dtos.IndexViewData{
|
data := dtos.IndexViewData{
|
||||||
User: &dtos.CurrentUser{
|
User: &dtos.CurrentUser{
|
||||||
Id: c.UserId,
|
Id: c.UserId,
|
||||||
|
11
pkg/middleware/dashboard_public.go
Normal file
11
pkg/middleware/dashboard_public.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetPublicDashboardFlag() func(c *models.ReqContext) {
|
||||||
|
return func(c *models.ReqContext) {
|
||||||
|
c.IsPublicDashboardView = true
|
||||||
|
}
|
||||||
|
}
|
@ -21,7 +21,8 @@ type ReqContext struct {
|
|||||||
SkipCache bool
|
SkipCache bool
|
||||||
Logger log.Logger
|
Logger log.Logger
|
||||||
// RequestNonce is a cryptographic request identifier for use with Content Security Policy.
|
// RequestNonce is a cryptographic request identifier for use with Content Security Policy.
|
||||||
RequestNonce string
|
RequestNonce string
|
||||||
|
IsPublicDashboardView bool
|
||||||
|
|
||||||
PerfmonTimer prometheus.Summary
|
PerfmonTimer prometheus.Summary
|
||||||
LookupTokenErr error
|
LookupTokenErr error
|
||||||
|
@ -95,6 +95,14 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const commandPaletteEnabled = () => !config.isPublicDashboardView && config.featureToggles.commandPalette;
|
||||||
|
|
||||||
|
const renderNavBar = () => {
|
||||||
|
return !config.isPublicDashboardView && ready && <>{newNavigationEnabled ? <NavBarNext /> : <NavBar />}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchBarEnabled = () => !config.isPublicDashboardView;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
@ -107,10 +115,10 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
|||||||
>
|
>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<GlobalStyles />
|
<GlobalStyles />
|
||||||
{config.featureToggles.commandPalette && <CommandPalette />}
|
{commandPaletteEnabled() && <CommandPalette />}
|
||||||
<div className="grafana-app">
|
<div className="grafana-app">
|
||||||
<Router history={locationService.getHistory()}>
|
<Router history={locationService.getHistory()}>
|
||||||
{ready && <>{newNavigationEnabled ? <NavBarNext /> : <NavBar />}</>}
|
{renderNavBar()}
|
||||||
<main className="main-view">
|
<main className="main-view">
|
||||||
{pageBanners.map((Banner, index) => (
|
{pageBanners.map((Banner, index) => (
|
||||||
<Banner key={index.toString()} />
|
<Banner key={index.toString()} />
|
||||||
@ -118,7 +126,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
|||||||
|
|
||||||
<AngularRoot />
|
<AngularRoot />
|
||||||
<AppNotificationList />
|
<AppNotificationList />
|
||||||
<SearchWrapper />
|
{searchBarEnabled() && <SearchWrapper />}
|
||||||
{ready && this.renderRoutes()}
|
{ready && this.renderRoutes()}
|
||||||
{bodyRenderHooks.map((Hook, index) => (
|
{bodyRenderHooks.map((Hook, index) => (
|
||||||
<Hook key={index.toString()} />
|
<Hook key={index.toString()} />
|
||||||
|
@ -39,7 +39,7 @@ export interface DashboardPageRouteParams {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardPageRouteSearchParams = {
|
export type DashboardPageRouteSearchParams = {
|
||||||
tab?: string;
|
tab?: string;
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
editPanel?: string;
|
editPanel?: string;
|
||||||
@ -67,7 +67,12 @@ const mapDispatchToProps = {
|
|||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
export type Props = Themeable2 &
|
type OwnProps = {
|
||||||
|
isPublic?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = OwnProps &
|
||||||
|
Themeable2 &
|
||||||
GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> &
|
GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> &
|
||||||
ConnectedProps<typeof connector>;
|
ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
@ -112,7 +117,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initDashboard() {
|
initDashboard() {
|
||||||
const { dashboard, match, queryParams } = this.props;
|
const { dashboard, isPublic, match, queryParams } = this.props;
|
||||||
|
|
||||||
if (dashboard) {
|
if (dashboard) {
|
||||||
this.closeDashboard();
|
this.closeDashboard();
|
||||||
@ -124,7 +129,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
|||||||
urlType: match.params.type,
|
urlType: match.params.type,
|
||||||
urlFolderId: queryParams.folderId,
|
urlFolderId: queryParams.folderId,
|
||||||
routeName: this.props.route.routeName,
|
routeName: this.props.route.routeName,
|
||||||
fixUrl: true,
|
fixUrl: !isPublic,
|
||||||
});
|
});
|
||||||
|
|
||||||
// small delay to start live updates
|
// small delay to start live updates
|
||||||
@ -312,9 +317,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { dashboard, initError, queryParams, theme } = this.props;
|
const { dashboard, initError, queryParams, theme, isPublic } = this.props;
|
||||||
const { editPanel, viewPanel, updateScrollTop } = this.state;
|
const { editPanel, viewPanel, updateScrollTop } = this.state;
|
||||||
const kioskMode = getKioskMode();
|
const kioskMode = !isPublic ? getKioskMode() : KioskMode.Full;
|
||||||
const styles = getStyles(theme, kioskMode);
|
const styles = getStyles(theme, kioskMode);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaRouteComponentProps } from '../../../core/navigation/types';
|
||||||
|
|
||||||
|
import DashboardPage, { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './DashboardPage';
|
||||||
|
|
||||||
|
export type Props = GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams>;
|
||||||
|
|
||||||
|
const PublicDashboardPage = (props: Props) => {
|
||||||
|
return <DashboardPage isPublic {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicDashboardPage;
|
@ -47,6 +47,9 @@ function setupTestContext(options: Partial<Props>) {
|
|||||||
panelInitialized: jest.fn(),
|
panelInitialized: jest.fn(),
|
||||||
getTimezone: () => 'browser',
|
getTimezone: () => 'browser',
|
||||||
events: new EventBusSrv(),
|
events: new EventBusSrv(),
|
||||||
|
meta: {
|
||||||
|
isPublic: false,
|
||||||
|
},
|
||||||
} as unknown as DashboardModel,
|
} as unknown as DashboardModel,
|
||||||
plugin: {
|
plugin: {
|
||||||
meta: { skipDataQuery: false },
|
meta: { skipDataQuery: false },
|
||||||
|
@ -20,10 +20,9 @@ import {
|
|||||||
toUtc,
|
toUtc,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { locationService, RefreshEvent } from '@grafana/runtime';
|
import { config, locationService, RefreshEvent } from '@grafana/runtime';
|
||||||
import { VizLegendOptions } from '@grafana/schema';
|
import { VizLegendOptions } from '@grafana/schema';
|
||||||
import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui';
|
import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui';
|
||||||
import config from 'app/core/config';
|
|
||||||
import { PANEL_BORDER } from 'app/core/constants';
|
import { PANEL_BORDER } from 'app/core/constants';
|
||||||
import { profiler } from 'app/core/profiler';
|
import { profiler } from 'app/core/profiler';
|
||||||
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { createEmptyQueryResponse } from '../../../explore/state/utils';
|
||||||
|
import { DashboardModel, PanelModel } from '../../state';
|
||||||
|
|
||||||
|
import { PanelHeader } from './PanelHeader';
|
||||||
|
|
||||||
|
let panelModel = new PanelModel({
|
||||||
|
id: 1,
|
||||||
|
gridPos: { x: 1, y: 1, w: 1, h: 1 },
|
||||||
|
type: 'type',
|
||||||
|
title: 'title',
|
||||||
|
});
|
||||||
|
|
||||||
|
let panelData = createEmptyQueryResponse();
|
||||||
|
|
||||||
|
describe('Panel Header', () => {
|
||||||
|
const dashboardModel = new DashboardModel({}, { isPublic: true });
|
||||||
|
it('will render header title but not render dropdown icon when dashboard is being viewed publicly', () => {
|
||||||
|
window.history.pushState({}, 'Test Title', '/public-dashboards/abc123');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PanelHeader panel={panelModel} dashboard={dashboardModel} isViewing={false} isEditing={false} data={panelData} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('title')).toBeDefined();
|
||||||
|
expect(screen.queryByTestId('panel-dropdown')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('will render header title and dropdown icon when dashboard is not being viewed publicly', () => {
|
||||||
|
const dashboardModel = new DashboardModel({}, { isPublic: false });
|
||||||
|
window.history.pushState({}, 'Test Title', '/d/abc/123');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PanelHeader panel={panelModel} dashboard={dashboardModel} isViewing={false} isEditing={false} data={panelData} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('title')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('panel-dropdown')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
@ -59,8 +59,17 @@ export const PanelHeader: FC<Props> = ({ panel, error, isViewing, isEditing, dat
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className={styles.titleText}>{title}</h2>
|
<h2 className={styles.titleText}>{title}</h2>
|
||||||
<Icon name="angle-down" className="panel-menu-toggle" />
|
{!dashboard.meta.isPublic && (
|
||||||
<PanelHeaderMenuWrapper panel={panel} dashboard={dashboard} show={panelMenuOpen} onClose={closeMenu} />
|
<div data-testid="panel-dropdown">
|
||||||
|
<Icon name="angle-down" className="panel-menu-toggle" />
|
||||||
|
<PanelHeaderMenuWrapper
|
||||||
|
panel={panel}
|
||||||
|
dashboard={dashboard}
|
||||||
|
show={panelMenuOpen}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.request && data.request.timeInfo && (
|
{data.request && data.request.timeInfo && (
|
||||||
<span className="panel-time-info">
|
<span className="panel-time-info">
|
||||||
<Icon name="clock-nine" size="sm" /> {data.request.timeInfo}
|
<Icon name="clock-nine" size="sm" /> {data.request.timeInfo}
|
||||||
|
24
public/app/features/dashboard/routes.ts
Normal file
24
public/app/features/dashboard/routes.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport';
|
||||||
|
import { config } from '../../core/config';
|
||||||
|
import { RouteDescriptor } from '../../core/navigation/types';
|
||||||
|
import { DashboardRoutes } from '../../types';
|
||||||
|
|
||||||
|
export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
|
||||||
|
if (config.featureToggles.publicDashboards) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: '/public-dashboards/:uid',
|
||||||
|
pageClass: 'page-dashboard',
|
||||||
|
routeName: DashboardRoutes.Public,
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "PublicDashboardPage" */ '../../features/dashboard/containers/PublicDashboardPage'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
@ -60,6 +60,15 @@ async function fetchDashboard(
|
|||||||
dashDTO.meta.canStar = false;
|
dashDTO.meta.canStar = false;
|
||||||
return dashDTO;
|
return dashDTO;
|
||||||
}
|
}
|
||||||
|
case DashboardRoutes.Public: {
|
||||||
|
const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
|
||||||
|
// Make sure new endpoint to fetch dashboard DTO sets these as false
|
||||||
|
dashDTO.meta.canEdit = false;
|
||||||
|
dashDTO.meta.canMakeEditable = false;
|
||||||
|
dashDTO.meta.isPublic = true;
|
||||||
|
|
||||||
|
return dashDTO;
|
||||||
|
}
|
||||||
case DashboardRoutes.Normal: {
|
case DashboardRoutes.Normal: {
|
||||||
const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
|
const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ import { AccessControlAction, DashboardRoutes } from 'app/types';
|
|||||||
|
|
||||||
import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
|
import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
|
||||||
import { RouteDescriptor } from '../core/navigation/types';
|
import { RouteDescriptor } from '../core/navigation/types';
|
||||||
|
import { getPublicDashboardRoutes } from '../features/dashboard/routes';
|
||||||
|
|
||||||
export const extraRoutes: RouteDescriptor[] = [];
|
export const extraRoutes: RouteDescriptor[] = [];
|
||||||
|
|
||||||
@ -429,6 +430,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
...getAlertingRoutes(),
|
...getAlertingRoutes(),
|
||||||
...getProfileRoutes(),
|
...getProfileRoutes(),
|
||||||
...extraRoutes,
|
...extraRoutes,
|
||||||
|
...getPublicDashboardRoutes(),
|
||||||
{
|
{
|
||||||
path: '/*',
|
path: '/*',
|
||||||
component: ErrorPage,
|
component: ErrorPage,
|
||||||
|
@ -66,6 +66,7 @@ export enum DashboardRoutes {
|
|||||||
New = 'new-dashboard',
|
New = 'new-dashboard',
|
||||||
Normal = 'normal-dashboard',
|
Normal = 'normal-dashboard',
|
||||||
Scripted = 'scripted-dashboard',
|
Scripted = 'scripted-dashboard',
|
||||||
|
Public = 'public-dashboard',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DashboardInitPhase {
|
export enum DashboardInitPhase {
|
||||||
|
Reference in New Issue
Block a user