diff --git a/.betterer.results b/.betterer.results
index 3054eb7cbd1..e4534e05a0b 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -3544,7 +3544,7 @@ exports[`better eslint`] = {
[11, 6, 169, "Do not use any type assertions.", "2248693214"],
[17, 11, 3, "Unexpected any. Specify a different type.", "193409811"]
],
- "public/app/core/navigation/types.ts:2017971154": [
+ "public/app/core/navigation/types.ts:2395305220": [
[10, 38, 3, "Unexpected any. Specify a different type.", "193409811"],
[14, 35, 3, "Unexpected any. Specify a different type.", "193409811"]
],
@@ -4653,8 +4653,8 @@ exports[`better eslint`] = {
[176, 29, 13, "Do not use any type assertions.", "2146830713"],
[195, 32, 3, "Unexpected any. Specify a different type.", "193409811"]
],
- "public/app/features/dashboard/components/DashNav/DashNav.tsx:574528540": [
- [66, 87, 3, "Unexpected any. Specify a different type.", "193409811"]
+ "public/app/features/dashboard/components/DashNav/DashNav.tsx:1533394562": [
+ [67, 87, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/dashboard/components/DashNav/DashNavTimeControls.test.tsx:3825334541": [
[49, 17, 3, "Unexpected any. Specify a different type.", "193409811"],
@@ -6580,16 +6580,16 @@ exports[`better eslint`] = {
"public/app/features/profile/state/reducers.test.ts:1105044753": [
[18, 15, 3, "Unexpected any. Specify a different type.", "193409811"]
],
- "public/app/features/query/components/QueryEditorRow.test.ts:589354782": [
- [5, 9, 60, "Do not use any type assertions.", "155833034"],
- [48, 22, 101, "Do not use any type assertions.", "3734235969"],
- [48, 22, 88, "Do not use any type assertions.", "3757401717"]
+ "public/app/features/query/components/QueryEditorRow.test.ts:4201471442": [
+ [7, 9, 60, "Do not use any type assertions.", "155833034"],
+ [50, 22, 101, "Do not use any type assertions.", "3734235969"],
+ [50, 22, 88, "Do not use any type assertions.", "3757401717"]
],
- "public/app/features/query/components/QueryEditorRow.tsx:3534885470": [
- [95, 22, 20, "Do not use any type assertions.", "2923490522"],
- [142, 18, 46, "Do not use any type assertions.", "1673417097"],
- [142, 18, 21, "Do not use any type assertions.", "1354497810"],
- [317, 22, 40, "Do not use any type assertions.", "1350130209"]
+ "public/app/features/query/components/QueryEditorRow.tsx:209136238": [
+ [97, 22, 20, "Do not use any type assertions.", "2923490522"],
+ [144, 18, 46, "Do not use any type assertions.", "1673417097"],
+ [144, 18, 21, "Do not use any type assertions.", "1354497810"],
+ [356, 22, 40, "Do not use any type assertions.", "1350130209"]
],
"public/app/features/query/components/QueryEditorRowHeader.test.tsx:2607197828": [
[99, 16, 32, "Do not use any type assertions.", "2255106576"]
diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts
index 5e179b851f5..120d1f35cf5 100644
--- a/packages/grafana-ui/src/types/icon.ts
+++ b/packages/grafana-ui/src/types/icon.ts
@@ -106,6 +106,7 @@ export const getAvailableIcons = () =>
'heart',
'heart-break',
'history',
+ 'home',
'home-alt',
'horizontal-align-center',
'horizontal-align-left',
@@ -179,6 +180,7 @@ export const getAvailableIcons = () =>
'vertical-align-center',
'vertical-align-top',
'wrap-text',
+ 'rss',
'x',
] as const;
diff --git a/public/app/core/components/TopNav/Breadcrumbs.tsx b/public/app/core/components/TopNav/Breadcrumbs.tsx
new file mode 100644
index 00000000000..bdfad00315e
--- /dev/null
+++ b/public/app/core/components/TopNav/Breadcrumbs.tsx
@@ -0,0 +1,82 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2, NavModelItem } from '@grafana/data';
+import { useStyles2, Icon, IconName } from '@grafana/ui';
+
+import { TopNavProps } from './TopNavUpdate';
+
+export interface Props extends TopNavProps {
+ sectionNav: NavModelItem;
+ subNav?: NavModelItem;
+}
+
+export interface Breadcrumb {
+ text?: string;
+ icon?: IconName;
+ href?: string;
+}
+
+export function Breadcrumbs({ sectionNav, subNav }: Props) {
+ const styles = useStyles2(getStyles);
+ const crumbs: Breadcrumb[] = [{ icon: 'home', href: '/' }];
+
+ function addCrumbs(node: NavModelItem) {
+ if (node.parentItem) {
+ addCrumbs(node.parentItem);
+ }
+
+ crumbs.push({ text: node.text, href: node.url });
+ }
+
+ addCrumbs(sectionNav);
+
+ if (subNav) {
+ addCrumbs(subNav);
+ }
+
+ return (
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ breadcrumbs: css({
+ display: 'flex',
+ alignItems: 'center',
+ fontWeight: theme.typography.fontWeightMedium,
+ }),
+ breadcrumb: css({
+ display: 'flex',
+ alignItems: 'center',
+ }),
+ separator: css({
+ color: theme.colors.text.secondary,
+ padding: theme.spacing(0, 0.5),
+ }),
+ breadcrumbLink: css({
+ color: theme.colors.text.primary,
+ '&:hover': {
+ textDecoration: 'underline',
+ },
+ }),
+ };
+};
diff --git a/public/app/core/components/TopNav/NavToolbar.tsx b/public/app/core/components/TopNav/NavToolbar.tsx
new file mode 100644
index 00000000000..afe8474d3d1
--- /dev/null
+++ b/public/app/core/components/TopNav/NavToolbar.tsx
@@ -0,0 +1,62 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2, NavModelItem } from '@grafana/data';
+import { IconButton, ToolbarButton, useStyles2 } from '@grafana/ui';
+
+import { Breadcrumbs } from './Breadcrumbs';
+import { TopNavProps } from './TopNavUpdate';
+import { TOP_BAR_LEVEL_HEIGHT } from './types';
+
+export interface Props extends TopNavProps {
+ onToggleSearchBar(): void;
+ searchBarHidden?: boolean;
+ sectionNav: NavModelItem;
+ subNav?: NavModelItem;
+}
+
+export function NavToolbar({ actions, onToggleSearchBar, searchBarHidden, sectionNav, subNav }: Props) {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+ {}} />
+
+
+
+
+ {actions}
+
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ pageToolbar: css({
+ height: TOP_BAR_LEVEL_HEIGHT,
+ display: 'flex',
+ padding: theme.spacing(0, 2),
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ }),
+ menuButton: css({
+ display: 'flex',
+ alignItems: 'center',
+ paddingRight: theme.spacing(1),
+ }),
+ leftActions: css({
+ display: 'flex',
+ alignItems: 'center',
+ flexGrow: 1,
+ gap: theme.spacing(2),
+ }),
+ rightActions: css({
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(2),
+ }),
+ };
+};
diff --git a/public/app/core/components/TopNav/TopNavPage.tsx b/public/app/core/components/TopNav/TopNavPage.tsx
new file mode 100644
index 00000000000..5924ad69485
--- /dev/null
+++ b/public/app/core/components/TopNav/TopNavPage.tsx
@@ -0,0 +1,80 @@
+import { css, cx } from '@emotion/css';
+import React, { PropsWithChildren } from 'react';
+import { useSelector } from 'react-redux';
+import { useObservable, useToggle } from 'react-use';
+import { createSelector } from 'reselect';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+import { getNavModel } from 'app/core/selectors/navModel';
+import { StoreState } from 'app/types';
+
+import { NavToolbar } from './NavToolbar';
+import { topNavDefaultProps, topNavUpdates } from './TopNavUpdate';
+import { TopSearchBar } from './TopSearchBar';
+import { TOP_BAR_LEVEL_HEIGHT } from './types';
+
+export interface Props extends PropsWithChildren<{}> {
+ /** This is nav tree id provided by route.
+ * It's not enough for item navigation. For that pages will need provide an item nav model as well via TopNavUpdate
+ */
+ navId?: string;
+}
+
+export function TopNavPage({ children, navId }: Props) {
+ const styles = useStyles2(getStyles);
+ const [searchBarHidden, toggleSearchBar] = useToggle(false); // repace with local storage
+ const props = useObservable(topNavUpdates, topNavDefaultProps);
+ const navModel = useSelector(createSelector(getNavIndex, (navIndex) => getNavModel(navIndex, navId ?? 'home')));
+
+ return (
+
+
+ {!searchBarHidden && }
+
+
+
{children}
+
+ );
+}
+
+function getNavIndex(store: StoreState) {
+ return store.navIndex;
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ const shadow = theme.isDark
+ ? `0 0.6px 1.5px rgb(0 0 0), 0 2px 4px rgb(0 0 0 / 40%), 0 5px 10px rgb(0 0 0 / 23%)`
+ : '0 0.6px 1.5px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 6%), 0 5px 10px rgb(0 0 0 / 5%)';
+
+ return {
+ viewport: css({
+ display: 'flex',
+ flexGrow: 1,
+ height: '100%',
+ }),
+ content: css({
+ display: 'flex',
+ paddingTop: TOP_BAR_LEVEL_HEIGHT * 2 + 16,
+ flexGrow: 1,
+ }),
+ contentNoSearchBar: css({
+ paddingTop: TOP_BAR_LEVEL_HEIGHT + 16,
+ }),
+ topNav: css({
+ display: 'flex',
+ position: 'fixed',
+ zIndex: theme.zIndex.navbarFixed,
+ left: 0,
+ right: 0,
+ boxShadow: shadow,
+ background: theme.colors.background.primary,
+ flexDirection: 'column',
+ }),
+ };
+};
diff --git a/public/app/core/components/TopNav/TopNavUpdate.tsx b/public/app/core/components/TopNav/TopNavUpdate.tsx
new file mode 100644
index 00000000000..c16ba7a2755
--- /dev/null
+++ b/public/app/core/components/TopNav/TopNavUpdate.tsx
@@ -0,0 +1,23 @@
+import { useEffect } from 'react';
+import { Subject } from 'rxjs';
+
+import { NavModelItem } from '@grafana/data';
+
+export interface TopNavProps {
+ subNav?: NavModelItem;
+ actions?: React.ReactNode;
+}
+
+export const topNavUpdates = new Subject();
+export const topNavDefaultProps: TopNavProps = {};
+
+/**
+ * This needs to be moved to @grafana/ui or runtime.
+ * This is the way core pages and plugins update the breadcrumbs and page toolbar actions
+ */
+export function TopNavUpdate(props: TopNavProps) {
+ useEffect(() => {
+ topNavUpdates.next(props);
+ });
+ return null;
+}
diff --git a/public/app/core/components/TopNav/TopSearchBar.tsx b/public/app/core/components/TopNav/TopSearchBar.tsx
new file mode 100644
index 00000000000..bc6f0863c14
--- /dev/null
+++ b/public/app/core/components/TopNav/TopSearchBar.tsx
@@ -0,0 +1,90 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { FilterInput, Icon, Tooltip, useStyles2 } from '@grafana/ui';
+import { contextSrv } from 'app/core/core';
+
+import { TOP_BAR_LEVEL_HEIGHT } from './types';
+
+export function TopSearchBar() {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+
+
+
+ {}}
+ className={styles.searchInput}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ searchBar: css({
+ height: TOP_BAR_LEVEL_HEIGHT,
+ display: 'flex',
+ padding: theme.spacing(0, 2),
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ border: `1px solid ${theme.colors.border.weak}`,
+ }),
+ logo: css({
+ display: 'flex',
+ }),
+ searchWrapper: css({}),
+ searchInput: css({}),
+ actions: css({
+ display: 'flex',
+ flexGrow: 0,
+ gap: theme.spacing(1),
+ position: 'relative',
+ width: 25, // this and the left pos is to make search input perfectly centered
+ left: -83,
+ }),
+ actionItem: css({
+ display: 'flex',
+ flexGrow: 0,
+ border: 'none',
+ boxShadow: 'none',
+ background: 'none',
+ alignItems: 'center',
+
+ color: theme.colors.text.secondary,
+ '&:hover': {
+ background: theme.colors.background.secondary,
+ },
+ img: {
+ borderRadius: '50%',
+ width: '24px',
+ height: '24px',
+ },
+ }),
+ };
+};
diff --git a/public/app/core/components/TopNav/types.ts b/public/app/core/components/TopNav/types.ts
new file mode 100644
index 00000000000..9171a881c48
--- /dev/null
+++ b/public/app/core/components/TopNav/types.ts
@@ -0,0 +1 @@
+export const TOP_BAR_LEVEL_HEIGHT = 40;
diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx
index 3ff44bc54bf..7b2e7445b0d 100644
--- a/public/app/core/navigation/GrafanaRoute.tsx
+++ b/public/app/core/navigation/GrafanaRoute.tsx
@@ -2,8 +2,9 @@ import React from 'react';
// @ts-ignore
import Drop from 'tether-drop';
-import { locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
+import { config, locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
+import { TopNavPage } from '../components/TopNav/TopNavPage';
import { keybindingSrv } from '../services/keybindingSrv';
import { GrafanaRouteComponentProps } from './types';
@@ -70,7 +71,12 @@ export class GrafanaRoute extends React.Component {
navigationLogger('GrafanaRoute', false, 'Rendered', props.route);
const RouteComponent = props.route.component;
+ const routeElement = ;
- return ;
+ if (config.featureToggles.topnav && !props.route.navHidden) {
+ return {routeElement};
+ }
+
+ return routeElement;
}
}
diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts
index aed3b16a62f..d34089aca8f 100644
--- a/public/app/core/navigation/types.ts
+++ b/public/app/core/navigation/types.ts
@@ -17,5 +17,7 @@ export interface RouteDescriptor {
pageClass?: string;
/** Can be used like an id for the route if the same component is used by many routes */
routeName?: string;
+ navHidden?: boolean;
exact?: boolean;
+ navId?: string;
}
diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx
index baf9919d11a..09101d40d5a 100644
--- a/public/app/features/dashboard/components/DashNav/DashNav.tsx
+++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx
@@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom';
import { locationUtil, textUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { ButtonGroup, ModalsController, ToolbarButton, PageToolbar, useForceUpdate } from '@grafana/ui';
+import { TopNavUpdate } from 'app/core/components/TopNav/TopNavUpdate';
import config from 'app/core/config';
import { toggleKioskMode } from 'app/core/navigation/kiosk';
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
@@ -274,6 +275,15 @@ export const DashNav = React.memo((props) => {
const parentHref = locationUtil.getUrlForPartial(location, { search: 'open', folder: 'current' });
const onGoBack = isFullscreen ? onClose : undefined;
+ if (config.featureToggles.topnav) {
+ return (
+ }
+ />
+ );
+ }
+
return (
import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage')
),
@@ -191,6 +193,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/users',
+ navId: 'users',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage')
),
@@ -203,6 +206,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/apikeys',
+ navId: 'apikeys',
roles: () => contextSrv.evaluatePermission(() => ['Admin'], [AccessControlAction.ActionAPIKeysRead]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
@@ -231,6 +235,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/teams',
+ navId: 'teams',
roles: () =>
contextSrv.evaluatePermission(
() => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']),
@@ -316,6 +321,7 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/login',
component: LoginPage,
pageClass: 'login-page sidemenu-hidden',
+ navHidden: true,
},
{
path: '/invite/:code',
@@ -323,6 +329,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "SignupInvited" */ 'app/features/invites/SignupInvited')
),
pageClass: 'sidemenu-hidden',
+ navHidden: true,
},
{
path: '/verify',
@@ -332,6 +339,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName "VerifyEmailPage"*/ 'app/core/components/Signup/VerifyEmailPage')
),
pageClass: 'login-page sidemenu-hidden',
+ navHidden: true,
},
{
path: '/signup',
@@ -339,10 +347,12 @@ export function getAppRoutes(): RouteDescriptor[] {
? () =>
: SafeDynamicImport(() => import(/* webpackChunkName "SignupPage"*/ 'app/core/components/Signup/SignupPage')),
pageClass: 'sidemenu-hidden login-page',
+ navHidden: true,
},
{
path: '/user/password/send-reset-email',
pageClass: 'sidemenu-hidden',
+ navHidden: true,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "SendResetMailPage" */ 'app/core/components/ForgottenPassword/SendResetMailPage')
@@ -357,6 +367,7 @@ export function getAppRoutes(): RouteDescriptor[] {
)
),
pageClass: 'sidemenu-hidden login-page',
+ navHidden: true,
},
{
path: '/dashboard/snapshots',