mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Group saved searches and dashboards by tab (#243)
https://github.com/hyperdxio/hyperdx/assets/149748269/ff496d3d-6f2d-4dd9-acc0-0e8f15eaaa58
This commit is contained in:
parent
5af48bb733
commit
5d02cc3c28
3 changed files with 268 additions and 104 deletions
5
.changeset/flat-kangaroos-jog.md
Normal file
5
.changeset/flat-kangaroos-jog.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hyperdx/app': patch
|
||||
---
|
||||
|
||||
Group saved searches and dashboards by tag
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from './config';
|
||||
import Icon from './Icon';
|
||||
import Logo from './Logo';
|
||||
import type { Dashboard, LogView } from './types';
|
||||
import { useLocalStorage, useWindowSize } from './utils';
|
||||
|
||||
import styles from '../styles/AppNav.module.scss';
|
||||
|
|
@ -490,6 +491,7 @@ function SearchInput({
|
|||
)
|
||||
}
|
||||
mt={8}
|
||||
mb="sm"
|
||||
size="xs"
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
|
|
@ -504,10 +506,83 @@ function SearchInput({
|
|||
);
|
||||
}
|
||||
|
||||
function useSearchableList<T extends { name: string }>({
|
||||
interface AppNavLinkItem {
|
||||
_id: string;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
type AppNavLinkGroup<T extends AppNavLinkItem> = {
|
||||
name: string;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
const AppNavGroupLabel = ({
|
||||
name,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
name: string;
|
||||
collapsed: boolean;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.listGroupName} onClick={onClick}>
|
||||
<i className={`bi bi-chevron-${collapsed ? 'right' : 'down'}`} />
|
||||
<div>{name}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNavLinkGroups = <T extends AppNavLinkItem>({
|
||||
name,
|
||||
groups,
|
||||
renderLink,
|
||||
forceExpandGroups = false,
|
||||
}: {
|
||||
name: string;
|
||||
groups: AppNavLinkGroup<T>[];
|
||||
renderLink: (item: T) => React.ReactNode;
|
||||
forceExpandGroups?: boolean;
|
||||
}) => {
|
||||
const [collapsedGroups, setCollapsedGroups] = useLocalStorage<
|
||||
Record<string, boolean>
|
||||
>(`collapsedGroups-${name}`, {});
|
||||
|
||||
const handleToggleGroup = useCallback(
|
||||
(groupName: string) => {
|
||||
setCollapsedGroups({
|
||||
...collapsedGroups,
|
||||
[groupName]: !collapsedGroups[groupName],
|
||||
});
|
||||
},
|
||||
[collapsedGroups, setCollapsedGroups],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(group => (
|
||||
<div key={group.name}>
|
||||
<AppNavGroupLabel
|
||||
onClick={() => handleToggleGroup(group.name)}
|
||||
name={group.name}
|
||||
collapsed={collapsedGroups[group.name]}
|
||||
/>
|
||||
<Collapse in={!collapsedGroups[group.name] || forceExpandGroups}>
|
||||
{group.items.map(item => renderLink(item))}
|
||||
</Collapse>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function useSearchableList<T extends AppNavLinkItem>({
|
||||
items,
|
||||
untaggedGroupName = 'Other',
|
||||
}: {
|
||||
items: T[];
|
||||
untaggedGroupName?: string;
|
||||
}) {
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
|
|
@ -528,8 +603,32 @@ function useSearchableList<T extends { name: string }>({
|
|||
return fuse.search(q).map(result => result.item);
|
||||
}, [fuse, items, q]);
|
||||
|
||||
const groupedFilteredList = useMemo<AppNavLinkGroup<T>[]>(() => {
|
||||
// group by tags
|
||||
const groupedItems: Record<string, T[]> = {};
|
||||
const untaggedItems: T[] = [];
|
||||
filteredList.forEach(item => {
|
||||
if (item.tags?.length) {
|
||||
item.tags.forEach(tag => {
|
||||
groupedItems[tag] = groupedItems[tag] ?? [];
|
||||
groupedItems[tag].push(item);
|
||||
});
|
||||
} else {
|
||||
untaggedItems.push(item);
|
||||
}
|
||||
});
|
||||
if (untaggedItems.length) {
|
||||
groupedItems[untaggedGroupName] = untaggedItems;
|
||||
}
|
||||
return Object.entries(groupedItems).map(([name, items]) => ({
|
||||
name,
|
||||
items,
|
||||
}));
|
||||
}, [filteredList, untaggedGroupName]);
|
||||
|
||||
return {
|
||||
filteredList,
|
||||
groupedFilteredList,
|
||||
q,
|
||||
setQ,
|
||||
};
|
||||
|
|
@ -631,17 +730,96 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
q: searchesListQ,
|
||||
setQ: setSearchesListQ,
|
||||
filteredList: filteredSearchesList,
|
||||
} = useSearchableList({ items: logViews });
|
||||
groupedFilteredList: groupedFilteredSearchesList,
|
||||
} = useSearchableList({
|
||||
items: logViews,
|
||||
untaggedGroupName: 'Saved Searches',
|
||||
});
|
||||
|
||||
const [isSearchPresetsCollapsed, setSearchPresetsCollapsed] = useLocalStorage(
|
||||
'isSearchPresetsCollapsed',
|
||||
false,
|
||||
);
|
||||
|
||||
const {
|
||||
q: dashboardsListQ,
|
||||
setQ: setDashboardsListQ,
|
||||
filteredList: filteredDashboardsList,
|
||||
} = useSearchableList({ items: dashboards });
|
||||
groupedFilteredList: groupedFilteredDashboardsList,
|
||||
} = useSearchableList({
|
||||
items: dashboards,
|
||||
untaggedGroupName: 'Saved Dashboards',
|
||||
});
|
||||
|
||||
const [isDashboardsPresetsCollapsed, setDashboardsPresetsCollapsed] =
|
||||
useLocalStorage('isDashboardsPresetsCollapsed', false);
|
||||
|
||||
const savedSearchesResultsRef = useRef<HTMLDivElement>(null);
|
||||
const dashboardsResultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderLogViewLink = useCallback(
|
||||
(lv: LogView) => (
|
||||
<Link
|
||||
href={`/search/${lv._id}?${new URLSearchParams(
|
||||
timeRangeQuery.from != -1 && timeRangeQuery.to != -1
|
||||
? {
|
||||
from: timeRangeQuery.from.toString(),
|
||||
to: timeRangeQuery.to.toString(),
|
||||
tq: inputTimeQuery,
|
||||
}
|
||||
: {},
|
||||
).toString()}`}
|
||||
key={lv._id}
|
||||
>
|
||||
<a
|
||||
tabIndex={0}
|
||||
className={cx(
|
||||
styles.listLink,
|
||||
lv._id === query.savedSearchId && styles.listLinkActive,
|
||||
)}
|
||||
title={lv.name}
|
||||
>
|
||||
<div className="d-inline-block text-truncate">{lv.name}</div>
|
||||
{Array.isArray(lv.alerts) && lv.alerts.length > 0 ? (
|
||||
lv.alerts.some(a => a.state === 'ALERT') ? (
|
||||
<i
|
||||
className="bi bi-bell float-end text-danger"
|
||||
title="Has Alerts and is in ALERT state"
|
||||
></i>
|
||||
) : (
|
||||
<i
|
||||
className="bi bi-bell float-end"
|
||||
title="Has Alerts and is in OK state"
|
||||
></i>
|
||||
)
|
||||
) : null}
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
[
|
||||
inputTimeQuery,
|
||||
query.savedSearchId,
|
||||
timeRangeQuery.from,
|
||||
timeRangeQuery.to,
|
||||
],
|
||||
);
|
||||
|
||||
const renderDashboardLink = useCallback(
|
||||
(dashboard: Dashboard) => (
|
||||
<Link href={`/dashboards/${dashboard._id}`} key={dashboard._id}>
|
||||
<a
|
||||
tabIndex={0}
|
||||
className={cx(styles.listLink, {
|
||||
[styles.listLinkActive]: dashboard._id === query.dashboardId,
|
||||
})}
|
||||
>
|
||||
{dashboard.name}
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
[query.dashboardId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthLoadingBlocker />
|
||||
|
|
@ -750,57 +928,21 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.listGroupName}>SAVED SEARCHES</div>
|
||||
|
||||
{logViews.length === 0 && (
|
||||
<div className={styles.listEmptyMsg}>
|
||||
No saved searches
|
||||
</div>
|
||||
)}
|
||||
<div ref={savedSearchesResultsRef}>
|
||||
{filteredSearchesList.map(lv => (
|
||||
<Link
|
||||
href={`/search/${lv._id}?${new URLSearchParams(
|
||||
timeRangeQuery.from != -1 &&
|
||||
timeRangeQuery.to != -1
|
||||
? {
|
||||
from: timeRangeQuery.from.toString(),
|
||||
to: timeRangeQuery.to.toString(),
|
||||
tq: inputTimeQuery,
|
||||
}
|
||||
: {},
|
||||
).toString()}`}
|
||||
key={lv._id}
|
||||
>
|
||||
<a
|
||||
tabIndex={0}
|
||||
className={cx(
|
||||
styles.listLink,
|
||||
lv._id === query.savedSearchId &&
|
||||
styles.listLinkActive,
|
||||
)}
|
||||
title={lv.name}
|
||||
>
|
||||
<div className="d-inline-block text-truncate">
|
||||
{lv.name}
|
||||
</div>
|
||||
{Array.isArray(lv.alerts) &&
|
||||
lv.alerts.length > 0 ? (
|
||||
lv.alerts.some(a => a.state === 'ALERT') ? (
|
||||
<i
|
||||
className="bi bi-bell float-end text-danger"
|
||||
title="Has Alerts and is in ALERT state"
|
||||
></i>
|
||||
) : (
|
||||
<i
|
||||
className="bi bi-bell float-end"
|
||||
title="Has Alerts and is in OK state"
|
||||
></i>
|
||||
)
|
||||
) : null}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
<AppNavLinkGroups
|
||||
name="saved-searches"
|
||||
groups={groupedFilteredSearchesList}
|
||||
renderLink={renderLogViewLink}
|
||||
forceExpandGroups={!!searchesListQ}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchesListQ && filteredSearchesList.length === 0 ? (
|
||||
<div className={styles.listEmptyMsg}>
|
||||
No results matching <i>{searchesListQ}</i>
|
||||
|
|
@ -808,15 +950,23 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className={styles.listGroupName}>PRESETS</div>
|
||||
<PresetSearchLink
|
||||
query="level:err OR level:crit OR level:fatal OR level:emerg OR level:alert"
|
||||
name="All Error Events"
|
||||
/>
|
||||
<PresetSearchLink
|
||||
query="http.status_code:>=400"
|
||||
name="HTTP Status >= 400"
|
||||
<AppNavGroupLabel
|
||||
name="Presets"
|
||||
collapsed={isSearchPresetsCollapsed}
|
||||
onClick={() =>
|
||||
setSearchPresetsCollapsed(!isSearchPresetsCollapsed)
|
||||
}
|
||||
/>
|
||||
<Collapse in={!isSearchPresetsCollapsed}>
|
||||
<PresetSearchLink
|
||||
query="level:err OR level:crit OR level:fatal OR level:emerg OR level:alert"
|
||||
name="All Error Events"
|
||||
/>
|
||||
<PresetSearchLink
|
||||
query="http.status_code:>=400"
|
||||
name="HTTP Status >= 400"
|
||||
/>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Collapse>
|
||||
)}
|
||||
|
|
@ -1023,32 +1173,20 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.listGroupName}>
|
||||
Saved Dashboards
|
||||
</div>
|
||||
|
||||
<AppNavLinkGroups
|
||||
name="dashboards"
|
||||
groups={groupedFilteredDashboardsList}
|
||||
renderLink={renderDashboardLink}
|
||||
forceExpandGroups={!!dashboardsListQ}
|
||||
/>
|
||||
|
||||
{dashboards.length === 0 && (
|
||||
<div className={styles.listEmptyMsg}>
|
||||
No saved dashboards
|
||||
</div>
|
||||
)}
|
||||
<div ref={dashboardsResultsRef}>
|
||||
{filteredDashboardsList.map((dashboard: any) => (
|
||||
<Link
|
||||
href={`/dashboards/${dashboard._id}`}
|
||||
key={dashboard._id}
|
||||
>
|
||||
<a
|
||||
tabIndex={0}
|
||||
className={cx(styles.listLink, {
|
||||
[styles.listLinkActive]:
|
||||
dashboard._id === query.dashboardId,
|
||||
})}
|
||||
>
|
||||
{dashboard.name}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dashboardsListQ &&
|
||||
filteredDashboardsList.length === 0 ? (
|
||||
<div className={styles.listEmptyMsg}>
|
||||
|
|
@ -1058,32 +1196,42 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.listGroupName}>PRESETS</div>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={HYPERDX_USAGE_DASHBOARD_CONFIG}
|
||||
name="HyperDX Usage"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={APP_PERFORMANCE_DASHBOARD_CONFIG}
|
||||
name="App Performance"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={HTTP_SERVER_DASHBOARD_CONFIG}
|
||||
name="HTTP Server"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={REDIS_DASHBOARD_CONFIG}
|
||||
name="Redis"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={MONGO_DASHBOARD_CONFIG}
|
||||
name="Mongo"
|
||||
<AppNavGroupLabel
|
||||
name="Presets"
|
||||
collapsed={isDashboardsPresetsCollapsed}
|
||||
onClick={() =>
|
||||
setDashboardsPresetsCollapsed(
|
||||
!isDashboardsPresetsCollapsed,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Collapse in={!isDashboardsPresetsCollapsed}>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={HYPERDX_USAGE_DASHBOARD_CONFIG}
|
||||
name="HyperDX Usage"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={APP_PERFORMANCE_DASHBOARD_CONFIG}
|
||||
name="App Performance"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={HTTP_SERVER_DASHBOARD_CONFIG}
|
||||
name="HTTP Server"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={REDIS_DASHBOARD_CONFIG}
|
||||
name="Redis"
|
||||
/>
|
||||
<PresetDashboardLink
|
||||
query={query}
|
||||
config={MONGO_DASHBOARD_CONFIG}
|
||||
name="Mongo"
|
||||
/>
|
||||
</Collapse>
|
||||
</div>
|
||||
</Collapse>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,20 @@
|
|||
.listGroupName {
|
||||
color: $slate-400;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 16px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// justify-content: space-between;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
color: $slate-300;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.listLink {
|
||||
|
|
@ -26,7 +35,6 @@
|
|||
text-decoration: none;
|
||||
color: $slate-300;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
|
@ -41,6 +49,9 @@
|
|||
outline: none;
|
||||
border-left: 2px solid $green;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.listEmptyMsg {
|
||||
|
|
|
|||
Loading…
Reference in a new issue