refactor(app-nav): reorganize AppNav component structure and improve maintainability (#1621)

This commit is contained in:
Elizabet Oliveira 2026-01-20 12:35:04 +00:00 committed by GitHub
parent bf553d68d9
commit 824a19a7b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 739 additions and 565 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
refactor(app-nav): reorganize AppNav component structure and improve maintainability

View file

@ -2,7 +2,6 @@ import React from 'react';
import Link from 'next/link';
import cx from 'classnames';
import {
ActionIcon,
Avatar,
Badge,
Button,
@ -27,12 +26,10 @@ import {
IconUserCog,
} from '@tabler/icons-react';
import { IS_LOCAL_MODE } from '@/config';
import InstallInstructionModal from '@/InstallInstructionsModal';
import { useSources } from '@/source';
import { IS_LOCAL_MODE } from './config';
import styles from '../styles/AppNav.module.scss';
import styles from './AppNav.module.scss';
export const AppNavContext = React.createContext<{
isCollapsed: boolean;
@ -84,8 +81,8 @@ export const AppNavUserMenu = ({
<Menu.Target>
<Paper
data-testid="user-menu-trigger"
className={cx(styles.userMenuTrigger, {
[styles.userMenuTriggerCollapsed]: isCollapsed,
className={cx(styles.userMenu, {
[styles.userMenuCollapsed]: isCollapsed,
})}
>
<Group gap="xs" wrap="nowrap" miw={0}>
@ -107,29 +104,16 @@ export const AppNavUserMenu = ({
}
openDelay={250}
>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div className={styles.userMenuInfo}>
<Text
size="xs"
fw="bold"
lh={1.1}
style={{
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
className={styles.userMenuName}
>
{displayName}
</Text>
<Text
size="xs"
style={{
fontSize: 11,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxHeight: 16,
}}
>
<Text size="xs" className={styles.userMenuTeam}>
{teamName}
</Text>
</div>
@ -179,18 +163,6 @@ export const AppNavUserMenu = ({
);
};
const useIsTeamHasNoData = () => {
const now = React.useMemo(() => new Date(), []);
const ago14d = React.useMemo(
() => new Date(Date.now() - 1000 * 60 * 60 * 24 * 14),
[],
);
const { data: sources } = useSources();
return Array.isArray(sources) && sources?.length > 0 ? false : true;
};
export const AppNavHelpMenu = ({
version,
onAddDataClick,
@ -202,16 +174,14 @@ export const AppNavHelpMenu = ({
const [
installModalOpen,
{ close: closeInstallModal, open: openInstallModal },
{ close: closeInstallModal, open: _openInstallModal },
] = useDisclosure(false);
// const isTeamHasNoData = useIsTeamHasNoData();
return (
<>
<Paper
className={cx(styles.helpMenuTrigger, {
[styles.helpMenuTriggerCollapsed]: isCollapsed,
className={cx(styles.helpButton, {
[styles.helpButtonCollapsed]: isCollapsed,
})}
>
<Menu
@ -291,45 +261,58 @@ export const AppNavLink = ({
}) => {
const { pathname, isCollapsed } = React.useContext(AppNavContext);
// Create a test id based on the href
const testId = `nav-link-${href.replace(/^\//, '').replace(/\//g, '-') || 'home'}`;
const handleToggleClick = (e: React.MouseEvent) => {
// Clicking chevron only toggles submenu, doesn't navigate
// This separates navigation (clicking link) from expand/collapse (clicking chevron)
e.preventDefault();
e.stopPropagation();
onToggle?.();
};
// Check if current path matches this nav item
// Use exact match or startsWith to avoid partial matches (e.g., /search matching /search-settings)
const isActive = pathname === href || pathname?.startsWith(href + '/');
return (
<Group justify="space-between" px="md" py="6px" h="34px">
<Link
data-testid={testId}
href={href}
className={cx(
styles.listLink,
{ [styles.listLinkActive]: pathname?.includes(href) },
className,
)}
style={{ display: 'flex', alignItems: 'center' }}
>
<span style={{ display: 'flex', alignItems: 'center' }}>
<span className={styles.linkIcon}>{icon}</span>
{!isCollapsed && <span>{label}</span>}
</span>
</Link>
<Link
data-testid={testId}
href={href}
className={cx(
styles.navItem,
{ [styles.navItemActive]: isActive },
className,
)}
>
<span className={styles.navItemContent}>
<span className={styles.navItemIcon}>{icon}</span>
{!isCollapsed && <span>{label}</span>}
</span>
{!isCollapsed && isBeta && (
<Badge size="xs" radius="sm" color="gray" style={{ marginRight: 8 }}>
<Badge
size="xs"
radius="sm"
color="gray"
className={styles.navItemBadge}
>
Beta
</Badge>
)}
{!isCollapsed && onToggle && (
<ActionIcon
<button
type="button"
data-testid={`${testId}-toggle`}
variant="subtle"
size="sm"
onClick={onToggle}
className={styles.navItemToggle}
onClick={handleToggleClick}
>
{isExpanded ? (
<IconChevronUp size={14} className="text-muted-hover" />
) : (
<IconChevronDown size={14} className="text-muted-hover" />
)}
</ActionIcon>
</button>
)}
</Group>
</Link>
);
};

View file

@ -0,0 +1,403 @@
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 16px;
$nav-item-height: 34px;
$header-height: 58px;
$help-button-size: 28px;
$search-input-height: 28px;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 10px;
$font-size-xs: 10px;
$font-size-sm: 11px;
$font-size-md: 13px;
$font-size-lg: 14px;
$transition-fast: 0.1s ease;
$transition-normal: 0.15s ease;
$transition-slow: 0.2s ease;
/* Mixins */
@mixin text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin interactive-press {
&:active {
transform: translate(0, 1px);
}
}
/* Layout */
.nav {
height: 100vh;
max-height: 100vh;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
border-right: 1px solid var(--color-border);
background: var(--color-bg-sidenav);
letter-spacing: 0.05em;
&Fixed {
position: fixed;
}
}
.header {
padding-inline: $spacing-lg;
padding-block: $spacing-md;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
&Expanded {
height: $header-height;
}
}
.logoLink {
text-decoration: none;
}
.logoIconWrapper {
margin-left: -0.15rem;
}
.collapseButton {
margin-right: -$spacing-xs;
margin-left: -$spacing-xs;
&Collapsed {
margin-top: $spacing-lg;
}
}
.scrollContainer {
max-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.footer {
position: absolute;
bottom: 0;
pointer-events: none;
background: linear-gradient(
to top,
var(--color-bg-sidenav) 50%,
transparent 100%
);
padding-top: 40px;
}
/* Navigation Links */
.navLinks {
display: flex;
flex-direction: column;
gap: 2px;
padding-inline: $spacing-sm;
margin-top: $spacing-sm;
}
.navItem {
display: flex;
justify-content: space-between;
align-items: center;
text-decoration: none;
color: var(--color-text-sidenav-link);
font-size: $font-size-lg;
@include text-truncate;
user-select: none;
gap: 10px;
padding-inline: $spacing-sm;
height: $nav-item-height;
border-radius: $radius-sm;
&:hover {
color: var(--color-text-sidenav-link-active);
background: var(--color-bg-sidenav-link);
}
&:focus-visible {
outline: none;
background: var(--color-bg-sidenav-link);
}
&Active {
color: var(--color-text-sidenav-link-active);
background: var(--color-bg-sidenav-link);
}
}
.navItemContent {
display: flex;
align-items: center;
}
.navItemIcon {
margin-right: $spacing-sm;
display: inline-flex;
align-items: center;
vertical-align: middle;
transition: color $transition-slow;
}
.navItemBadge {
margin-left: auto;
}
.navItemToggle {
@include flex-center;
padding: $spacing-xs;
margin-right: -$spacing-xs;
background: none;
border: none;
cursor: pointer;
border-radius: $radius-sm;
color: var(--color-text-muted);
transition:
background-color $transition-normal,
color $transition-normal,
transform $transition-fast;
&:hover {
background: var(--color-bg-sidenav-link-hover);
color: var(--color-text-sidenav-link-active);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
/* Sub-menu (Collapsible sections) */
.subMenu {
overflow-x: hidden;
max-width: 100%;
padding: $spacing-xs $spacing-xs;
padding-bottom: 0;
}
.subMenuItem {
display: block;
color: var(--color-text-sidenav-link);
text-decoration: none;
font-size: $font-size-md;
padding-left: $spacing-lg;
padding-block: 2px;
transition: color $transition-slow;
@include text-truncate;
border-radius: $radius-sm;
margin-bottom: 2px;
&:hover {
color: var(--color-text-sidenav-link-active);
background: var(--color-bg-sidenav-link);
}
&:focus-visible {
outline: none;
background: var(--color-bg-sidenav-link);
}
i {
vertical-align: top;
}
&Active {
color: var(--color-text-sidenav-link-active);
background: var(--color-bg-sidenav-link);
}
}
/* Groups (Saved Searches, Dashboards) */
.groupLabel {
color: var(--color-text-muted);
text-transform: uppercase;
font-size: $font-size-sm;
letter-spacing: 1px;
margin-bottom: 2px;
margin-top: 6px;
padding-left: $spacing-lg;
width: 100%;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
&:hover {
color: var(--color-text-sidenav-link);
}
}
.groupDragOver {
opacity: 0.7;
background: #ffffff1a;
border-radius: $radius-lg;
}
/* Search Input */
.searchInput {
input {
min-height: $search-input-height;
height: $search-input-height;
line-height: $search-input-height;
}
}
.shortcutHint {
white-space: nowrap;
letter-spacing: -1px;
color: var(--color-text-muted);
background: var(--color-bg-surface);
border-radius: $radius-sm;
padding: 0 $spacing-xs;
font-size: $font-size-xs;
margin-right: $spacing-lg;
&Ctrl {
letter-spacing: -2px;
}
}
/* Empty States */
.emptyMessage {
color: var(--color-text-muted);
font-size: $font-size-md - 1px;
margin: $spacing-sm $spacing-lg;
@include text-truncate;
}
/* User Menu */
.userMenu {
pointer-events: all;
cursor: pointer;
margin: $spacing-sm;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-md;
transition:
background-color $transition-slow,
transform $transition-fast;
max-width: 100%;
overflow: hidden;
&:hover {
background: var(--color-bg-sidenav-link);
}
@include interactive-press;
&Collapsed {
padding: 2px;
margin: $spacing-sm auto 0.875rem;
background: transparent;
border: none !important;
width: fit-content;
&:hover {
background: var(--color-bg-sidenav-link);
}
@include interactive-press;
}
}
.userMenuInfo {
flex: 1;
overflow: hidden;
}
.userMenuName {
max-width: 100%;
@include text-truncate;
}
.userMenuTeam {
font-size: $font-size-sm;
max-width: 100%;
max-height: $spacing-lg;
@include text-truncate;
}
/* Help Button */
.helpButton {
@include flex-center;
pointer-events: all;
cursor: pointer;
margin: 0 $spacing-sm $spacing-sm;
width: $help-button-size;
height: $help-button-size;
border-radius: 50%;
border: 1px solid var(--color-border);
transition:
background-color $transition-slow,
transform $transition-fast,
border-color $transition-slow;
&:hover {
background: var(--color-bg-sidenav-link);
border-color: var(--color-border);
}
@include interactive-press;
&Collapsed {
margin: 0 auto $spacing-sm;
border: none;
background: transparent;
&:hover {
background: var(--color-bg-sidenav-link);
}
@include interactive-press;
}
}
/* Onboarding Section */
.onboardingSection {
padding-left: $spacing-sm;
padding-right: $spacing-md;
margin-bottom: $spacing-sm;
margin-top: $spacing-lg;
padding-bottom: 80px;
}
/* Scrollbar Customization */
.scrollbar {
.thumb {
background-color: var(--color-border);
}
}

View file

@ -1,8 +1,8 @@
import type { Meta } from '@storybook/nextjs';
import type { StoryObj } from '@storybook/nextjs';
import AppNav from '../AppNav';
import { AppNavUserMenu } from '../AppNav.components';
import AppNav from './AppNav';
import { AppNavUserMenu } from './AppNav.components';
const meta: Meta = {
component: AppNav,

View file

@ -15,7 +15,6 @@ import { AlertState } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Badge,
Box,
Button,
CloseButton,
Collapse,
@ -41,20 +40,25 @@ import {
IconTable,
} from '@tabler/icons-react';
import api from '@/api';
import { IS_K8S_DASHBOARD_ENABLED, IS_LOCAL_MODE } from '@/config';
import {
useCreateDashboard,
useDashboards,
useUpdateDashboard,
} from '@/dashboard';
import Icon from '@/Icon';
import InstallInstructionModal from '@/InstallInstructionsModal';
import Logo from '@/Logo';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches, useUpdateSavedSearch } from '@/savedSearch';
import type { SavedSearch, ServerDashboard } from '@/types';
import { UserPreferencesModal } from '@/UserPreferencesModal';
import { useUserPreferences } from '@/useUserPreferences';
import { useWindowSize } from '@/utils';
import packageJson from '../package.json';
import packageJson from '../../../package.json';
// Expose the same value Next injected at build time; fall back to package.json for dev tooling
const APP_VERSION =
process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev';
import api from './api';
import {
AppNavCloudBanner,
AppNavContext,
@ -62,21 +66,55 @@ import {
AppNavLink,
AppNavUserMenu,
} from './AppNav.components';
import { IS_K8S_DASHBOARD_ENABLED, IS_LOCAL_MODE } from './config';
import Icon from './Icon';
import InstallInstructionModal from './InstallInstructionsModal';
import Logo from './Logo';
import OnboardingChecklist from './OnboardingChecklist';
import { useSavedSearches, useUpdateSavedSearch } from './savedSearch';
import type { SavedSearch, ServerDashboard } from './types';
import { UserPreferencesModal } from './UserPreferencesModal';
import { useWindowSize } from './utils';
import styles from '../styles/AppNav.module.scss';
import styles from './AppNav.module.scss';
// Expose the same value Next injected at build time; fall back to package.json for dev tooling
const APP_VERSION =
process.env.NEXT_PUBLIC_APP_VERSION ?? packageJson.version ?? 'dev';
const UNTAGGED_SEARCHES_GROUP_NAME = 'Saved Searches';
const UNTAGGED_DASHBOARDS_GROUP_NAME = 'Saved Dashboards';
// Navigation link configuration
type NavLinkConfig = {
id: string;
label: string;
href: string;
icon: React.ReactNode;
isBeta?: boolean;
cloudOnly?: boolean; // Only show when not in local mode
};
const NAV_LINKS: NavLinkConfig[] = [
{
id: 'chart',
label: 'Chart Explorer',
href: '/chart',
icon: <IconChartDots size={16} />,
},
{
id: 'alerts',
label: 'Alerts',
href: '/alerts',
icon: <IconBell size={16} />,
cloudOnly: true,
},
{
id: 'sessions',
label: 'Client Sessions',
href: '/sessions',
icon: <IconDeviceLaptop size={16} />,
},
{
id: 'service-map',
label: 'Service Map',
href: '/service-map',
icon: <IconSitemap size={16} />,
isBeta: true,
},
];
function NewDashboardButton() {
const createDashboard = useCreateDashboard();
@ -138,11 +176,11 @@ function SearchInput({
}) {
const kbdShortcut = useMemo(() => {
return (
<div className={styles.kbd}>
<div className={styles.shortcutHint}>
{window.navigator.platform?.toUpperCase().includes('MAC') ? (
<IconCommand size={8} />
) : (
<span style={{ letterSpacing: -2 }}>Ctrl</span>
<span className={styles.shortcutHintCtrl}>Ctrl</span>
)}
&nbsp;K
</div>
@ -212,7 +250,7 @@ const AppNavGroupLabel = ({
onClick: () => void;
}) => {
return (
<div className={styles.listGroupName} onClick={onClick}>
<div className={styles.groupLabel} onClick={onClick}>
{collapsed ? (
<IconChevronRight size={14} />
) : (
@ -260,9 +298,7 @@ const AppNavLinkGroups = <T extends AppNavLinkItem>({
{groups.map(group => (
<div
key={group.name}
className={cx(
draggingOver === group.name && styles.listGroupDragEnter,
)}
className={cx(draggingOver === group.name && styles.groupDragOver)}
onDragOver={e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
@ -424,7 +460,6 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
const isCollapsed = isSmallScreen || isPreferCollapsed;
const navWidth = isCollapsed ? 50 : 230;
const navHeaderStyle = isCollapsed ? undefined : { height: 58 };
useEffect(() => {
HyperDX.addAction('user navigated', {
@ -488,8 +523,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
key={savedSearch.id}
tabIndex={0}
className={cx(
styles.nestedLink,
savedSearch.id === query.savedSearchId && styles.nestedLinkActive,
styles.subMenuItem,
savedSearch.id === query.savedSearchId && styles.subMenuItemActive,
)}
title={savedSearch.name}
draggable
@ -556,8 +591,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
href={`/dashboards/${dashboard.id}`}
key={dashboard.id}
tabIndex={0}
className={cx(styles.nestedLink, {
[styles.nestedLinkActive]: dashboard.id === query.dashboardId,
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]: dashboard.id === query.dashboardId,
})}
draggable
data-dashboardid={dashboard.id}
@ -618,20 +653,19 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
onHide={closeInstallInstructions}
/>
<div
className={`${styles.wrapper}`}
style={{
position: fixed ? 'fixed' : 'initial',
letterSpacing: '0.05em',
}}
className={cx(styles.nav, {
[styles.navFixed]: fixed,
})}
>
<div style={{ width: navWidth }}>
<div
className="p-3 d-flex flex-wrap justify-content-between align-items-center"
style={navHeaderStyle}
className={cx(styles.header, {
[styles.headerExpanded]: !isCollapsed,
})}
>
<Link href="/search" className="text-decoration-none">
<Link href="/search" className={styles.logoLink}>
{isCollapsed ? (
<div style={{ marginLeft: '-0.15rem' }}>
<div className={styles.logoIconWrapper}>
<Icon size={22} />
</div>
) : (
@ -654,8 +688,9 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
<ActionIcon
variant="transparent"
size="sm"
className={isCollapsed ? 'mt-4' : ''}
style={{ marginRight: -4, marginLeft: -4 }}
className={cx(styles.collapseButton, {
[styles.collapseButtonCollapsed]: isCollapsed,
})}
title="Collapse/Expand Navigation"
onClick={() => setIsPreferCollapsed((v: boolean) => !v)}
>
@ -667,240 +702,209 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
type="scroll"
scrollbarSize={6}
scrollHideDelay={100}
style={{
maxHeight: '100%',
height: '100%',
}}
classNames={styles}
className="d-flex flex-column justify-content-between"
className={styles.scrollContainer}
>
<div style={{ width: navWidth }}>
<div className="mt-2">
<AppNavLink
label="Search"
icon={<IconTable size={16} />}
href="/search"
className={cx({
'text-success fw-600':
pathname.includes('/search') && query.savedSearchId == null,
'fw-600':
pathname.includes('/search') && query.savedSearchId != null,
})}
isExpanded={isSearchExpanded}
onToggle={
!IS_LOCAL_MODE
? () => setIsSearchExpanded(!isSearchExpanded)
: undefined
}
/>
<div style={{ width: navWidth }} className={styles.navLinks}>
{/* Search */}
<AppNavLink
label="Search"
icon={<IconTable size={16} />}
href="/search"
isExpanded={isSearchExpanded}
onToggle={
!IS_LOCAL_MODE
? () => setIsSearchExpanded(!isSearchExpanded)
: undefined
}
/>
{!isCollapsed && (
<Collapse in={isSearchExpanded}>
<div className={styles.list}>
{isLogViewsLoading ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
!IS_LOCAL_MODE && (
<>
<SearchInput
placeholder="Saved Searches"
value={searchesListQ}
onChange={setSearchesListQ}
onEnterDown={() => {
(
savedSearchesResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
{!isCollapsed && (
<Collapse in={isSearchExpanded}>
<div className={styles.subMenu}>
{isLogViewsLoading ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
!IS_LOCAL_MODE && (
<>
<SearchInput
placeholder="Saved Searches"
value={searchesListQ}
onChange={setSearchesListQ}
onEnterDown={() => {
(
savedSearchesResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
{logViews.length === 0 && (
<div className={styles.listEmptyMsg}>
No saved searches
</div>
)}
<div ref={savedSearchesResultsRef}>
<AppNavLinkGroups
name="saved-searches"
groups={groupedFilteredSearchesList}
renderLink={renderLogViewLink}
forceExpandGroups={!!searchesListQ}
onDragEnd={handleLogViewDragEnd}
/>
{logViews.length === 0 && (
<div className={styles.emptyMessage}>
No saved searches
</div>
{searchesListQ &&
filteredSearchesList.length === 0 ? (
<div className={styles.listEmptyMsg}>
No results matching <i>{searchesListQ}</i>
</div>
) : null}
</>
)
)}
</div>
</Collapse>
)}
<AppNavLink
label="Chart Explorer"
href="/chart"
icon={<IconChartDots size={16} />}
/>
{!IS_LOCAL_MODE && (
<AppNavLink
label="Alerts"
href="/alerts"
icon={<IconBell size={16} />}
/>
)}
<AppNavLink
label="Client Sessions"
href="/sessions"
icon={<IconDeviceLaptop size={16} />}
/>
<AppNavLink
label="Service Map"
href="/service-map"
icon={<IconSitemap size={16} />}
isBeta
/>
<AppNavLink
label="Dashboards"
href="/dashboards"
icon={<IconLayoutGrid size={16} />}
isExpanded={isDashboardsExpanded}
onToggle={() => setIsDashboardExpanded(!isDashboardsExpanded)}
/>
{!isCollapsed && (
<Collapse in={isDashboardsExpanded}>
<div className={styles.list}>
<NewDashboardButton />
{isDashboardsLoading ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
!IS_LOCAL_MODE && (
<>
<SearchInput
placeholder="Saved Dashboards"
value={dashboardsListQ}
onChange={setDashboardsListQ}
onEnterDown={() => {
(
dashboardsResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
)}
<div ref={savedSearchesResultsRef}>
<AppNavLinkGroups
name="dashboards"
/* @ts-ignore */
groups={groupedFilteredDashboardsList}
renderLink={renderDashboardLink}
forceExpandGroups={!!dashboardsListQ}
onDragEnd={handleDashboardDragEnd}
name="saved-searches"
groups={groupedFilteredSearchesList}
renderLink={renderLogViewLink}
forceExpandGroups={!!searchesListQ}
onDragEnd={handleLogViewDragEnd}
/>
</div>
{dashboards.length === 0 && (
<div className={styles.listEmptyMsg}>
No saved dashboards
</div>
)}
{searchesListQ && filteredSearchesList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{searchesListQ}</i>
</div>
) : null}
</>
)
)}
</div>
</Collapse>
)}
{/* Simple nav links from config */}
{NAV_LINKS.filter(link => !link.cloudOnly || !IS_LOCAL_MODE).map(
link => (
<AppNavLink
key={link.id}
label={link.label}
href={link.href}
icon={link.icon}
isBeta={link.isBeta}
/>
),
)}
{dashboardsListQ &&
filteredDashboardsList.length === 0 ? (
<div className={styles.listEmptyMsg}>
No results matching <i>{dashboardsListQ}</i>
</div>
) : null}
</>
{/* Dashboards */}
<AppNavLink
label="Dashboards"
href="/dashboards"
icon={<IconLayoutGrid size={16} />}
isExpanded={isDashboardsExpanded}
onToggle={() => setIsDashboardExpanded(!isDashboardsExpanded)}
/>
{!isCollapsed && (
<Collapse in={isDashboardsExpanded}>
<div className={styles.subMenu}>
<NewDashboardButton />
{isDashboardsLoading ? (
<Loader variant="dots" mx="md" my="xs" size="sm" />
) : (
!IS_LOCAL_MODE && (
<>
<SearchInput
placeholder="Saved Dashboards"
value={dashboardsListQ}
onChange={setDashboardsListQ}
onEnterDown={() => {
(
dashboardsResultsRef?.current
?.firstChild as HTMLAnchorElement
)?.focus?.();
}}
/>
<AppNavLinkGroups
name="dashboards"
groups={groupedFilteredDashboardsList}
renderLink={renderDashboardLink}
forceExpandGroups={!!dashboardsListQ}
onDragEnd={handleDashboardDragEnd}
/>
{dashboards.length === 0 && (
<div className={styles.emptyMessage}>
No saved dashboards
</div>
)}
{dashboardsListQ &&
filteredDashboardsList.length === 0 ? (
<div className={styles.emptyMessage}>
No results matching <i>{dashboardsListQ}</i>
</div>
) : null}
</>
)
)}
<AppNavGroupLabel
name="Presets"
collapsed={isDashboardsPresetsCollapsed}
onClick={() =>
setDashboardsPresetsCollapsed(
!isDashboardsPresetsCollapsed,
)
)}
<AppNavGroupLabel
name="Presets"
collapsed={isDashboardsPresetsCollapsed}
onClick={() =>
setDashboardsPresetsCollapsed(
!isDashboardsPresetsCollapsed,
)
}
/>
<Collapse in={!isDashboardsPresetsCollapsed}>
<Link
href={`/clickhouse`}
tabIndex={0}
className={cx(styles.nestedLink, {
[styles.nestedLinkActive]:
pathname.startsWith('/clickhouse'),
})}
data-testid="nav-link-clickhouse-dashboard"
>
ClickHouse
</Link>
<Link
href={`/services`}
tabIndex={0}
className={cx(styles.nestedLink, {
[styles.nestedLinkActive]:
pathname.startsWith('/services'),
})}
data-testid="nav-link-services-dashboard"
>
Services
</Link>
{IS_K8S_DASHBOARD_ENABLED && (
<Link
href={`/kubernetes`}
tabIndex={0}
className={cx(styles.nestedLink, {
[styles.nestedLinkActive]:
pathname.startsWith('/kubernetes'),
})}
data-testid="nav-link-k8s-dashboard"
>
Kubernetes
</Link>
)}
</Collapse>
</div>
</Collapse>
)}
{!IS_LOCAL_MODE && (
<Box mt="sm">
<AppNavLink
label="Team Settings"
href="/team"
icon={<IconSettings size={16} />}
}
/>
</Box>
)}
</div>
<Collapse in={!isDashboardsPresetsCollapsed}>
<Link
href={`/clickhouse`}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]:
pathname.startsWith('/clickhouse'),
})}
data-testid="nav-link-clickhouse-dashboard"
>
ClickHouse
</Link>
<Link
href={`/services`}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]:
pathname.startsWith('/services'),
})}
data-testid="nav-link-services-dashboard"
>
Services
</Link>
{IS_K8S_DASHBOARD_ENABLED && (
<Link
href={`/kubernetes`}
tabIndex={0}
className={cx(styles.subMenuItem, {
[styles.subMenuItemActive]:
pathname.startsWith('/kubernetes'),
})}
data-testid="nav-link-k8s-dashboard"
>
Kubernetes
</Link>
)}
</Collapse>
</div>
</Collapse>
)}
{/* Team Settings (Cloud only) */}
{!IS_LOCAL_MODE && (
<AppNavLink
label="Team Settings"
href="/team"
icon={<IconSettings size={16} />}
/>
)}
</div>
{!isCollapsed && (
<>
<div
style={{ width: navWidth, paddingBottom: 80 }}
className="px-3 mb-2 mt-4"
>
<OnboardingChecklist onAddDataClick={openInstallInstructions} />
<AppNavCloudBanner />
</div>
</>
<div
style={{ width: navWidth }}
className={styles.onboardingSection}
>
<OnboardingChecklist onAddDataClick={openInstallInstructions} />
<AppNavCloudBanner />
</div>
)}
</ScrollArea>
<div
className={styles.bottomSection}
style={{
width: navWidth,
}}
>
<div className={styles.footer} style={{ width: navWidth }}>
<AppNavHelpMenu
version={APP_VERSION}
onAddDataClick={openInstallInstructions}

View file

@ -0,0 +1,8 @@
export { default } from './AppNav';
export {
AppNavCloudBanner,
AppNavContext,
AppNavHelpMenu,
AppNavLink,
AppNavUserMenu,
} from './AppNav.components';

View file

@ -1,6 +1,7 @@
import React from 'react';
import AppNav from './AppNav';
import AppNav from '@/components/AppNav';
import { HDXSpotlightProvider } from './Spotlights';
/**

View file

@ -1,238 +0,0 @@
.wrapper {
height: 100vh;
max-height: 100vh;
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
border-right: 1px solid var(--color-border);
background: var(--color-bg-sidebar);
}
.list {
border-top: 1px solid var(--color-border);
overflow-x: hidden;
max-width: 100%;
padding: 4px 16px;
padding-bottom: 0;
}
.scrollbar {
.thumb {
background-color: var(--color-border);
}
}
.listGroupDragEnter {
opacity: 0.7;
background: #ffffff1a;
border-radius: 10px;
}
.listGroupName {
color: var(--color-text-muted);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 1px;
margin-bottom: 2px;
margin-top: 6px;
padding-left: 16px;
width: 100%;
display: flex;
align-items: center;
gap: 6px;
&:hover {
color: var(--color-text);
cursor: pointer;
}
}
.listLink {
display: flex;
justify-content: space-between;
text-decoration: none;
color: var(--color-text);
font-size: 14px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
gap: 10px;
&:hover {
color: var(--color-text-success);
}
&:focus-visible {
outline: none;
background: var(--color-bg-muted);
}
}
.linkIcon {
margin-right: 8px;
display: inline-flex;
align-items: center;
vertical-align: middle;
transition: all 0.2s ease-in-out;
&:hover {
color: var(--color-text-success);
}
}
.listEmptyMsg {
color: var(--color-text-muted);
font-size: 12px;
margin: 8px 16px;
overflow: hidden;
text-overflow: ellipsis;
}
.nestedLink {
display: block;
color: var(--color-text);
text-decoration: none;
font-size: 13px;
padding-left: 16px;
padding-block: 2px;
transition: all 0.2s ease-in-out;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--color-text-success);
}
&:focus-visible {
outline: none;
background: var(--color-bg-muted);
}
i {
vertical-align: top;
}
}
.nestedLinkActive {
color: var(--color-text-success);
font-weight: 500;
}
.listLinkActive {
color: var(--color-text-success);
font-weight: 500;
}
.kbd {
white-space: nowrap;
letter-spacing: -1px;
color: var(--color-text-muted);
background: var(--color-bg-surface);
border-radius: 4px;
padding: 0 4px;
font-size: 10px;
margin-right: 16px;
}
.searchInput {
input {
min-height: 28px;
height: 28px;
line-height: 28px;
}
}
// User menu trigger styling
.userMenuTrigger {
pointer-events: all;
cursor: pointer;
margin: 8px 0.875rem 0.875rem;
padding: 4px 8px;
border-radius: 8px;
transition:
background-color 0.2s ease,
transform 0.1s ease;
max-width: 100%;
overflow: hidden;
&:hover {
background: var(--color-bg-muted);
}
&:active {
transform: translate(0, 1px);
}
&Collapsed {
padding: 2px;
margin: 8px auto 0.875rem;
background: transparent;
border: none !important;
width: fit-content;
&:hover {
background: var(--color-bg-muted);
}
&:active {
transform: translate(0, 1px);
}
}
}
// Help menu trigger styling
.helpMenuTrigger {
pointer-events: all;
cursor: pointer;
margin: 0 0.875rem 8px;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--color-border);
transition:
background-color 0.2s ease,
transform 0.1s ease,
border-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-bg-muted);
border-color: var(--color-border);
}
&:active {
transform: translate(0, 1px);
}
&Collapsed {
margin: 0 auto 8px;
border: none;
background: transparent;
&:hover {
background: var(--color-bg-muted);
}
&:active {
transform: translate(0, 1px);
}
}
}
// Bottom section with gradient overlay
.bottomSection {
position: absolute;
bottom: 0;
pointer-events: none;
background: linear-gradient(
to top,
var(--color-bg-sidebar) 50%,
transparent 100%
);
padding-top: 40px;
}

View file

@ -6,7 +6,7 @@
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-bg-sidebar);
background-color: var(--color-bg-body);
word-break: break-all;
flex-shrink: 0;
flex-grow: 0;

View file

@ -6,7 +6,9 @@
--color-bg-inverted: var(--mantine-color-dark-1);
--color-bg-muted: var(--mantine-color-dark-7);
--color-bg-highlighted: rgb(55 58 64 / 70%);
--color-bg-sidebar: var(--mantine-color-dark-9);
--color-bg-sidenav: var(--mantine-color-dark-9);
--color-bg-sidenav-link: var(--mantine-color-dark-6);
--color-bg-sidenav-link-hover: var(--mantine-color-dark-7);
--color-bg-header: var(--mantine-color-dark-9);
--color-bg-hover: var(--mantine-color-dark-6);
--color-bg-active: var(--mantine-color-dark-5);
@ -33,6 +35,8 @@
--color-text-success: var(--mantine-color-green-4);
--color-text-success-hover: var(--mantine-color-green-3);
--color-text-danger: var(--mantine-color-red-3);
--color-text-sidenav-link: var(--mantine-color-dark-0);
--color-text-sidenav-link-active: var(--color-text-primary);
/* Icons */
--color-icon-primary: var(--mantine-color-dark-1);
@ -76,7 +80,9 @@
--color-bg-inverted: var(--mantine-color-dark-3);
--color-bg-muted: #f6f6fa;
--color-bg-highlighted: rgb(230 232 237 / 70%);
--color-bg-sidebar: var(--mantine-color-white);
--color-bg-sidenav: var(--mantine-color-white);
--color-bg-sidenav-link: #f6f6fa;
--color-bg-sidenav-link-hover: var(--mantine-color-gray-2);
--color-bg-header: var(--mantine-color-gray-1);
--color-bg-modal: var(--mantine-color-white);
--color-bg-hover: var(--mantine-color-gray-3);
@ -104,6 +110,8 @@
--color-text-success: var(--mantine-color-green-8);
--color-text-success-hover: var(--mantine-color-green-9);
--color-text-danger: var(--mantine-color-red-8);
--color-text-sidenav-link: var(--mantine-color-dark-6);
--color-text-sidenav-link-active: var(--mantine-color-green-9);
/* Icons - inverted */
--color-icon-primary: var(--mantine-color-dark-8);