mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 06:30:40 +01:00
Compare commits
3 Commits
feat/ai-tr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375b3f8305 | ||
|
|
aaec7c89c5 | ||
|
|
57c193ec8f |
@@ -49,6 +49,7 @@
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
|
||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ importers:
|
||||
'@grafana/data':
|
||||
specifier: ^11.6.15
|
||||
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
|
||||
@@ -18,13 +18,11 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
index,
|
||||
version,
|
||||
panelType,
|
||||
signalSource = '',
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
panelType: PANEL_TYPES | null;
|
||||
signalSource: string;
|
||||
}): JSX.Element {
|
||||
const { setAggregationOptions } = useQueryBuilderV2Context();
|
||||
const {
|
||||
@@ -206,7 +204,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,7 +240,6 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -316,7 +316,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
|
||||
version="v4"
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && (
|
||||
{showFilterCollapse && handleFilterVisibilityChange && (
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ComponentProps, ReactNode, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
@@ -7,13 +8,17 @@ import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
type QuickFiltersElementProps = Omit<
|
||||
JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>,
|
||||
'handleFilterVisibilityChange'
|
||||
>;
|
||||
|
||||
// What the page configures; the layout adds the open / close wiring.
|
||||
@@ -26,6 +31,7 @@ export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps?: QuickFiltersElementProps;
|
||||
savedViewProps?: SavedViewsElementProps;
|
||||
showFilters: boolean;
|
||||
onToggleFilters?: () => void;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
@@ -36,6 +42,7 @@ function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
savedViewProps,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
@@ -60,6 +67,7 @@ function QuickFiltersLayout({
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
@@ -71,29 +79,32 @@ function QuickFiltersLayout({
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<div
|
||||
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
|
||||
data-testid="quick-filters-layout-drawer"
|
||||
>
|
||||
{hasQuickFilters ? (
|
||||
<QuickFilters
|
||||
{...quickFilterProps}
|
||||
handleFilterVisibilityChange={onToggleFilters}
|
||||
savedViewsHeader={savedViewsHeader}
|
||||
/>
|
||||
) : (
|
||||
savedViewsHeader
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
@@ -35,20 +36,28 @@ jest.mock('../../QuickFilters', () => ({
|
||||
default: ({
|
||||
source,
|
||||
savedViewsHeader,
|
||||
handleFilterVisibilityChange,
|
||||
}: {
|
||||
source: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{source}
|
||||
{handleFilterVisibilityChange && (
|
||||
<Button
|
||||
aria-label="Collapse Filters"
|
||||
data-testid="quick-filters-collapse"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
@@ -233,8 +242,54 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-panel-header'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
|
||||
it('collapses from the quick filters header when they are shown', async () => {
|
||||
const onToggleFilters = jest.fn();
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
onToggleFilters={onToggleFilters}
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('saved-views-collapse')).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('quick-filters-collapse'));
|
||||
expect(onToggleFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('collapses from the saved views header without quick filters', async () => {
|
||||
const onToggleFilters = jest.fn();
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
onToggleFilters={onToggleFilters}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-collapse'));
|
||||
expect(onToggleFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has no collapse without a toggle', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('saved-views-collapse')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;
|
||||
@@ -27,18 +27,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.qf-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qf-header-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +52,17 @@
|
||||
}
|
||||
|
||||
.search {
|
||||
.ant-input {
|
||||
background-color: var(--l1-background);
|
||||
height: 46px;
|
||||
border-radius: 0;
|
||||
}
|
||||
--input-height: var(--spacing-20);
|
||||
--input-border-radius: 0;
|
||||
--input-border-color: transparent;
|
||||
--input-hover-border-color: transparent;
|
||||
--input-focus-border-color: transparent;
|
||||
--input-focus-outline-width: 0;
|
||||
--input-background: var(--l1-background);
|
||||
--input-hover-background: var(--l1-background);
|
||||
--input-focus-background: var(--l1-background);
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.qf-other-filters-list {
|
||||
@@ -125,6 +128,7 @@
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
@@ -43,14 +44,17 @@ function QuickFiltersSettings({
|
||||
<>
|
||||
<div className="qf-header">
|
||||
<div className="qf-title">
|
||||
<TableColumnsSplit width={16} height={16} />
|
||||
<TableColumnsSplit size={16} />
|
||||
Edit quick filters
|
||||
</div>
|
||||
<X
|
||||
className="qf-header-icon"
|
||||
width={16}
|
||||
height={16}
|
||||
<SignozButton
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
onClick={handleSettingsClose}
|
||||
data-testid="quick-filters-settings-close"
|
||||
/>
|
||||
</div>
|
||||
<section className="search">
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface QuickFilterChangeEventData {
|
||||
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange: () => void;
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
@@ -138,16 +137,6 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_PIPELINES),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToLogsViews],
|
||||
keywords: 'logs views',
|
||||
section: 'Logs',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.LOGS_SAVE_VIEWS),
|
||||
},
|
||||
|
||||
// metrics
|
||||
{
|
||||
@@ -170,16 +159,6 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'metrics-views',
|
||||
name: 'Go to Metrics Views',
|
||||
shortcut: [GlobalShortcutsName.NavigateToMetricsViews],
|
||||
keywords: 'metrics views',
|
||||
section: 'Metrics',
|
||||
icon: <TowerControl size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_VIEWS),
|
||||
},
|
||||
|
||||
// Traces
|
||||
{
|
||||
|
||||
@@ -10,17 +10,14 @@ export const GlobalShortcuts = {
|
||||
// logs
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
|
||||
// traces
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
|
||||
// metrics
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
|
||||
// settings
|
||||
NavigateToSettings: 'shift+g',
|
||||
@@ -42,10 +39,8 @@ export const GlobalShortcutsName = {
|
||||
ToggleSidebar: 'shift+b',
|
||||
NavigateToHome: 'shift+h',
|
||||
NavigateToTracesFunnel: 'shift+t+f',
|
||||
NavigateToTracesViews: 'shift+t+v',
|
||||
NavigateToMetricsSummary: 'shift+m',
|
||||
NavigateToMetricsExplorer: 'shift+m+e',
|
||||
NavigateToMetricsViews: 'shift+m+v',
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
@@ -55,7 +50,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
NavigateToLogs: 'shift+l',
|
||||
NavigateToLogsPipelines: 'shift+l+p',
|
||||
NavigateToLogsViews: 'shift+l+v',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsDescription = {
|
||||
@@ -69,17 +63,14 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
NavigateToTracesFunnel: 'Navigate to Traces Funnel',
|
||||
NavigateToTracesViews: 'Navigate to Traces Views',
|
||||
NavigateToMetricsSummary: 'Navigate to Metrics Summary',
|
||||
NavigateToMetricsExplorer: 'Navigate to Metrics Explorer',
|
||||
NavigateToMetricsViews: 'Navigate to Metrics Views',
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToLogsViews: 'Navigate to Logs Views',
|
||||
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
|
||||
NavigateToSettingsRoles: 'Navigate to Roles Settings',
|
||||
NavigateToSettingsMembers: 'Navigate to Members Settings',
|
||||
|
||||
@@ -26,9 +26,7 @@ function Explorer(): JSX.Element {
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
function ExplorerOrderBy({ query, onChange }: OrderByFilterProps): JSX.Element {
|
||||
const {
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
aggregationOptions,
|
||||
generateOptions,
|
||||
createOptions,
|
||||
handleChange,
|
||||
handleSearchKeys,
|
||||
} = useOrderByFilter({ query, onChange });
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const keysOptions = createOptions(data?.payload?.attributeKeys || []);
|
||||
|
||||
const customOptions = createOptions([
|
||||
{ key: 'timestamp', type: '', dataType: DataTypes.EMPTY },
|
||||
]);
|
||||
|
||||
const baseOptions = [
|
||||
...customOptions,
|
||||
...(query.aggregateOperator === StringOperators.NOOP
|
||||
? []
|
||||
: aggregationOptions),
|
||||
...keysOptions,
|
||||
];
|
||||
|
||||
return generateOptions(baseOptions);
|
||||
}, [
|
||||
aggregationOptions,
|
||||
createOptions,
|
||||
data?.payload?.attributeKeys,
|
||||
generateOptions,
|
||||
query.aggregateOperator,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
showSearch
|
||||
showArrow={false}
|
||||
value={selectedValue}
|
||||
labelInValue
|
||||
options={options}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ExplorerOrderBy);
|
||||
@@ -12,6 +12,7 @@ import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery'
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function SavedViews({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<
|
||||
SavedviewtypesSavedViewDTO[]
|
||||
@@ -351,7 +353,7 @@ export default function SavedViews({
|
||||
{selectedEntityViews.length > 0 ? renderSavedViews() : emptyStateCard()}
|
||||
</Card.Content>
|
||||
|
||||
{selectedEntityViews.length > 0 && (
|
||||
{!isSavedViewEnabled && selectedEntityViews.length > 0 && (
|
||||
<Card.Footer>
|
||||
<div className="services-footer home-data-card-footer">
|
||||
<Link to={footerLink}>
|
||||
|
||||
@@ -190,14 +190,12 @@ function Explorer(): JSX.Element {
|
||||
className="trace-explorer-page"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
onToggleFilters={(): void => setOpen(!isOpen)}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
@@ -82,6 +83,7 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
@@ -516,13 +518,15 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
<GoToTop />
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
onExport={handleExport}
|
||||
sourcepage={DataSource.LOGS}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
@@ -17,6 +18,7 @@ import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashb
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -38,6 +40,7 @@ function Explorer(): JSX.Element {
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
@@ -123,15 +126,14 @@ function Explorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
onToggleFilters={(): void => setShowQuickFilters(!showQuickFilters)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.meter }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
@@ -177,15 +179,17 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import {
|
||||
@@ -64,10 +68,12 @@ function Explorer(): JSX.Element {
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
@@ -350,12 +356,26 @@ function Explorer(): JSX.Element {
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
showFilters={showSidebar}
|
||||
onToggleFilters={(): void => setShowSidebar(!showSidebar)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
|
||||
>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
{!showSidebar && (
|
||||
<TooltipSimple title="Show Views">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Show Views"
|
||||
prefix={<Filter size={16} />}
|
||||
onClick={(): void => setShowSidebar(true)}
|
||||
data-testid="metrics-explorer-show-sidebar"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<span>1 chart/query</span>
|
||||
<Tooltip
|
||||
open={disableOneChartPerQuery ? undefined : false}
|
||||
@@ -405,15 +425,17 @@ function Explorer(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
<MetricDetails
|
||||
metricName={selectedMetricName}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Total, not Partial, so a signal added to the generated enum has to be mapped here
|
||||
// before it compiles.
|
||||
const SIGNAL_TO_DATA_SOURCE: Record<
|
||||
TelemetrytypesSignalDTO,
|
||||
DataSource | undefined
|
||||
> = {
|
||||
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
|
||||
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
|
||||
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
|
||||
// The "unset" member: not a data source a query can be built against.
|
||||
[TelemetrytypesSignalDTO['']]: undefined,
|
||||
};
|
||||
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
|
||||
|
||||
export function signalsToDataSources(
|
||||
signals: readonly TelemetrytypesSignalDTO[],
|
||||
|
||||
@@ -5,5 +5,4 @@ export type GroupByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: BaseAutocompleteData[]) => void;
|
||||
disabled: boolean;
|
||||
signalSource?: string;
|
||||
};
|
||||
|
||||
@@ -6,39 +6,37 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
// ** Constants
|
||||
import { idDivider, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { idDivider } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
// ** Components
|
||||
// ** Helpers
|
||||
import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
DATA_SOURCE_TO_SIGNAL,
|
||||
QUERY_SOURCE_TO_FIELD_SOURCE,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import { getMetricNameForQuery } from '../utils';
|
||||
import OptionRenderer from '../OptionRenderer/OptionRenderer';
|
||||
import { GroupByFilterProps } from './GroupByFilter.interfaces';
|
||||
import { toAttributeKey } from './utils';
|
||||
|
||||
export const GroupByFilter = memo(function GroupByFilter({
|
||||
query,
|
||||
onChange,
|
||||
disabled,
|
||||
signalSource,
|
||||
}: GroupByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [optionsData, setOptionsData] = useState<
|
||||
SelectOption<string, ReactNode>[]
|
||||
>([]);
|
||||
const [localValues, setLocalValues] = useState<SelectOption<string, string>[]>(
|
||||
[],
|
||||
);
|
||||
@@ -46,74 +44,40 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
|
||||
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
if (signalSource === 'meter') {
|
||||
return 'meter' as DataSource;
|
||||
}
|
||||
return query.dataSource;
|
||||
}, [signalSource, query.dataSource]);
|
||||
|
||||
const { isFetching } = useGetAggregateKeys(
|
||||
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
|
||||
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
|
||||
metricName: getMetricNameForQuery(query),
|
||||
searchText: debouncedValue,
|
||||
},
|
||||
{
|
||||
queryKey: [debouncedValue, isFocused],
|
||||
enabled: !disabled && isFocused,
|
||||
onSuccess: (data) => {
|
||||
const keys = query.groupBy.reduce<string[]>((acc, item) => {
|
||||
acc.push(item.key);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const filteredOptions: BaseAutocompleteData[] =
|
||||
data?.payload?.attributeKeys?.filter(
|
||||
(attrKey) => !keys.includes(attrKey.key),
|
||||
) || [];
|
||||
|
||||
const options: SelectOption<string, ReactNode>[] =
|
||||
filteredOptions.map((item) => ({
|
||||
label: (
|
||||
<OptionRenderer
|
||||
key={item.key}
|
||||
label={item.key}
|
||||
value={item.key}
|
||||
dataType={item.dataType || ''}
|
||||
type={item.type || ''}
|
||||
/>
|
||||
),
|
||||
value: `${item.id}`,
|
||||
})) || [];
|
||||
|
||||
setOptionsData(options);
|
||||
},
|
||||
},
|
||||
query.builderQueryType,
|
||||
{ enabled: !disabled && isFocused, keepPreviousData: true },
|
||||
);
|
||||
|
||||
const getAttributeKeys = useCallback(async () => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, searchText, isFocused],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
searchText,
|
||||
}),
|
||||
);
|
||||
const attributeKeys = useMemo(
|
||||
() => (fieldKeys ?? []).map(toAttributeKey),
|
||||
[fieldKeys],
|
||||
);
|
||||
|
||||
return response.payload?.attributeKeys || [];
|
||||
}, [
|
||||
isFocused,
|
||||
query.aggregateAttribute?.key,
|
||||
query.aggregateOperator,
|
||||
dataSource,
|
||||
queryClient,
|
||||
searchText,
|
||||
]);
|
||||
const optionsData = useMemo((): SelectOption<string, ReactNode>[] => {
|
||||
const selectedKeys = new Set(query.groupBy.map((item) => item.key));
|
||||
|
||||
return attributeKeys
|
||||
.filter((item) => !selectedKeys.has(item.key))
|
||||
.map((item) => ({
|
||||
label: (
|
||||
<OptionRenderer
|
||||
key={item.key}
|
||||
label={item.key}
|
||||
value={item.key}
|
||||
dataType={item.dataType || ''}
|
||||
type={item.type || ''}
|
||||
/>
|
||||
),
|
||||
value: `${item.id}`,
|
||||
}));
|
||||
}, [attributeKeys, query.groupBy]);
|
||||
|
||||
const handleSearchKeys = (searchText: string): void => {
|
||||
setSearchText(searchText);
|
||||
@@ -129,15 +93,13 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
};
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (values: SelectOption<string, string>[]): Promise<void> => {
|
||||
const keys = await getAttributeKeys();
|
||||
|
||||
(values: SelectOption<string, string>[]): void => {
|
||||
const groupByValues: BaseAutocompleteData[] = values.map((item) => {
|
||||
const id = item.value;
|
||||
const currentValue = item.value.split(idDivider)[0];
|
||||
|
||||
if (id && id.includes(idDivider)) {
|
||||
const attribute = keys.find((item) => item.id === id);
|
||||
const attribute = attributeKeys.find((item) => item.id === id);
|
||||
const existAttribute = query.groupBy.find((item) => item.id === id);
|
||||
|
||||
if (attribute) {
|
||||
@@ -149,14 +111,14 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
}
|
||||
}
|
||||
|
||||
return chooseAutocompleteFromCustomValue(keys, currentValue);
|
||||
return chooseAutocompleteFromCustomValue(attributeKeys, currentValue);
|
||||
});
|
||||
|
||||
const result = uniqWith(groupByValues, isEqual);
|
||||
|
||||
onChange(result);
|
||||
},
|
||||
[getAttributeKeys, onChange, query.groupBy],
|
||||
[attributeKeys, onChange, query.groupBy],
|
||||
);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
|
||||
@@ -9,137 +9,140 @@ import { IBuilderQuery } from '../../../../../types/api/queryBuilder/queryBuilde
|
||||
import { DataSource } from '../../../../../types/common/queryBuilder';
|
||||
import { GroupByFilter } from '../GroupByFilter';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
|
||||
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
|
||||
|
||||
function setup(
|
||||
overrides?: Partial<React.ComponentProps<typeof GroupByFilter>>,
|
||||
): { onChange: jest.Mock } {
|
||||
type FieldKeyVariant = {
|
||||
name: string;
|
||||
fieldContext: string;
|
||||
fieldDataType?: string;
|
||||
};
|
||||
|
||||
const fieldKeysBody = (
|
||||
variants: FieldKeyVariant[],
|
||||
): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: variants.reduce<Record<string, FieldKeyVariant[]>>(
|
||||
(acc, variant) => ({
|
||||
...acc,
|
||||
[variant.name]: [...(acc[variant.name] ?? []), variant],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const groupByInput = (): HTMLInputElement =>
|
||||
screen.getByTestId('group-by').querySelector('input') as HTMLInputElement;
|
||||
|
||||
function setup(queryOverrides?: Partial<IBuilderQuery>): {
|
||||
onChange: jest.Mock;
|
||||
} {
|
||||
const onChange = jest.fn();
|
||||
const query = {
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregateAttribute: { key: 'service.name' },
|
||||
aggregateAttribute: { key: 'signoz_calls_total' },
|
||||
aggregateOperator: 'COUNT',
|
||||
groupBy: [],
|
||||
...queryOverrides,
|
||||
} as unknown as IBuilderQuery;
|
||||
render(
|
||||
<GroupByFilter
|
||||
query={query}
|
||||
onChange={onChange}
|
||||
disabled={false}
|
||||
signalSource="meter"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
render(<GroupByFilter query={query} onChange={onChange} disabled={false} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
// wait for dropdown and select the option instead of hitting Enter
|
||||
async function clickOption(label: string): Promise<void> {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const option = await screen.findByText(
|
||||
(content, el) =>
|
||||
content.trim() === label && !!el?.closest('.ant-select-item-option'),
|
||||
);
|
||||
await user.click(option);
|
||||
}
|
||||
|
||||
describe('GroupByFilter', () => {
|
||||
const dataSourceCalls: string[] = [];
|
||||
let callCount = 0;
|
||||
const requests: URLSearchParams[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
dataSourceCalls.length = 0;
|
||||
callCount = 0;
|
||||
requests.length = 0;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('uses meter datasource for suggestions and normalization', async () => {
|
||||
function useFieldKeys(variants: FieldKeyVariant[]): void {
|
||||
server.use(
|
||||
rest.get(attributeKeysURL, (req, res, ctx) => {
|
||||
const ds = req.url.searchParams.get('dataSource') || '';
|
||||
dataSourceCalls.push(ds);
|
||||
callCount += 1;
|
||||
const attributeKeys =
|
||||
callCount === 1
|
||||
? [
|
||||
{
|
||||
id: 'service.name--string--',
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { attributeKeys },
|
||||
}),
|
||||
);
|
||||
rest.get(fieldKeysURL, (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(ctx.status(200), ctx.json(fieldKeysBody(variants)));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { onChange } = setup();
|
||||
it('asks the fields API for meter keys and emits a typed custom key', async () => {
|
||||
useFieldKeys([]);
|
||||
const { onChange } = setup({ source: 'meter' });
|
||||
|
||||
const select = screen.getByTestId('group-by');
|
||||
fireEvent.focus(select);
|
||||
fireEvent.focus(screen.getByTestId('group-by'));
|
||||
|
||||
await waitFor(() => expect(dataSourceCalls.length).toBeGreaterThanOrEqual(1));
|
||||
expect(dataSourceCalls[0]).toBe('meter');
|
||||
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
|
||||
expect(requests[0].get('signal')).toBe('metrics');
|
||||
expect(requests[0].get('source')).toBe('meter');
|
||||
expect(requests[0].get('metricName')).toBe('signoz_calls_total');
|
||||
|
||||
const input = select.querySelector('input') as HTMLInputElement;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.type(input, 'custom.attr');
|
||||
// wait for dropdown and select the option instead of hitting Enter
|
||||
await screen.findByRole('listbox');
|
||||
const option = await screen.findByRole('option', { name: 'custom.attr' });
|
||||
await user.click(option);
|
||||
await user.type(groupByInput(), 'custom.attr');
|
||||
await clickOption('custom.attr');
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(dataSourceCalls[dataSourceCalls.length - 1]).toBe('meter');
|
||||
|
||||
const emitted = onChange.mock.calls[0][0][0];
|
||||
expect(emitted.key).toBe('custom.attr');
|
||||
expect(emitted.id).toBeTruthy();
|
||||
expect(emitted.id).not.toBe('----');
|
||||
});
|
||||
it('clicks suggested option and emits proper value', async () => {
|
||||
server.use(
|
||||
rest.get(attributeKeysURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: [
|
||||
{
|
||||
id: 'service.name--string--',
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
it('emits the picked suggestion with its context and data type', async () => {
|
||||
useFieldKeys([
|
||||
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
|
||||
]);
|
||||
const { onChange } = setup();
|
||||
const combo = screen.getByRole('combobox');
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(combo);
|
||||
await user.click(groupByInput());
|
||||
await clickOption('service.name');
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
const option = await screen.findByText(
|
||||
(content, el) =>
|
||||
content.trim() === 'service.name' &&
|
||||
!!el?.closest('.ant-select-item-option'),
|
||||
);
|
||||
await user.click(option);
|
||||
|
||||
// Expect payload to be resolved to the exact attribute returned by MSW
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
||||
const emitted = onChange.mock.calls[0][0][0];
|
||||
expect(emitted.key).toBe('service.name');
|
||||
expect(emitted.id).toBe('service.name--string--');
|
||||
// Expect payload to be resolved to the exact attribute returned by MSW
|
||||
expect(onChange.mock.calls[0][0][0]).toMatchObject({
|
||||
key: 'service.name',
|
||||
type: 'resource',
|
||||
dataType: 'string',
|
||||
id: 'service.name--string--resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('offers each context of a name as its own option', async () => {
|
||||
useFieldKeys([
|
||||
{ name: 'service.name', fieldContext: 'resource', fieldDataType: 'string' },
|
||||
{ name: 'service.name', fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
]);
|
||||
setup();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(groupByInput());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByText('service.name')
|
||||
.filter((el) => !!el.closest('.ant-select-item-option')),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
baseAutoCompleteIdKeysOrder,
|
||||
initialAutocompleteData,
|
||||
} from 'constants/queryBuilder';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
export const toAttributeKey = (
|
||||
key: TelemetryFieldKey,
|
||||
): BaseAutocompleteData => {
|
||||
const fields = {
|
||||
key: key.name,
|
||||
dataType: fieldDataTypeToDataType(key.fieldDataType),
|
||||
type: key.fieldContext ?? '',
|
||||
};
|
||||
|
||||
return {
|
||||
...initialAutocompleteData,
|
||||
...fields,
|
||||
id: createIdFromObjectFields(fields, baseAutoCompleteIdKeysOrder),
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
DATA_SOURCE_TO_SIGNAL,
|
||||
DataSource,
|
||||
MetricAggregateOperator,
|
||||
QUERY_SOURCE_TO_FIELD_SOURCE,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import { getMetricNameForQuery } from '../utils';
|
||||
import { OrderByFilterProps } from './OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from './useOrderByFilter';
|
||||
|
||||
@@ -27,13 +33,14 @@ export function OrderByFilter({
|
||||
handleSearchKeys,
|
||||
} = useOrderByFilter({ query, onChange, entityVersion });
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
|
||||
source: QUERY_SOURCE_TO_FIELD_SOURCE[query.source ?? ''],
|
||||
metricName: getMetricNameForQuery(query),
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
query.builderQueryType,
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key || isRawQuery,
|
||||
keepPreviousData: true,
|
||||
@@ -47,9 +54,11 @@ export function OrderByFilter({
|
||||
);
|
||||
|
||||
const optionsData = useMemo(() => {
|
||||
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
|
||||
const groupByOptions = createOptions(query.groupBy);
|
||||
const aggregationOptionsFromParsed = createOptions(parsedAggregationOptions);
|
||||
const keyOptions = createOptions((fieldKeys ?? []).map((key) => key.name));
|
||||
const groupByOptions = createOptions(query.groupBy.map((item) => item.key));
|
||||
const aggregationOptionsFromParsed = createOptions(
|
||||
parsedAggregationOptions.map((item) => item.key),
|
||||
);
|
||||
|
||||
const options =
|
||||
query.aggregateOperator === MetricAggregateOperator.NOOP
|
||||
@@ -62,7 +71,7 @@ export function OrderByFilter({
|
||||
return generateOptions(options);
|
||||
}, [
|
||||
createOptions,
|
||||
data?.payload?.attributeKeys,
|
||||
fieldKeys,
|
||||
query.groupBy,
|
||||
query.aggregateOperator,
|
||||
parsedAggregationOptions,
|
||||
@@ -92,6 +101,7 @@ export function OrderByFilter({
|
||||
options={optionsData}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
data-testid="order-by"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { OrderByFilter } from '../OrderByFilter';
|
||||
|
||||
const fieldKeysURL = `${ENVIRONMENT.baseURL}/api/v1/fields/keys`;
|
||||
|
||||
const fieldKeysBody = (names: string[]): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [name, [{ name, fieldContext: 'resource' }]]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const orderByInput = (): HTMLInputElement =>
|
||||
screen.getByTestId('order-by').querySelector('input') as HTMLInputElement;
|
||||
|
||||
function setup(query: Partial<IBuilderQuery>): { onChange: jest.Mock } {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<OrderByFilter
|
||||
query={
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: { key: '' },
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
...query,
|
||||
} as unknown as IBuilderQuery
|
||||
}
|
||||
onChange={onChange}
|
||||
isRawQuery
|
||||
isNewQueryV2
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
describe('OrderByFilter', () => {
|
||||
const requests: URLSearchParams[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
requests.length = 0;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('offers fields API keys in both directions for a raw query', async () => {
|
||||
server.use(
|
||||
rest.get(fieldKeysURL, (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(ctx.status(200), ctx.json(fieldKeysBody(['service.name'])));
|
||||
}),
|
||||
);
|
||||
const { onChange } = setup({});
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(orderByInput());
|
||||
|
||||
await waitFor(() => expect(requests.length).toBeGreaterThanOrEqual(1));
|
||||
expect(requests[0].get('signal')).toBe('logs');
|
||||
|
||||
await screen.findByText('service.name desc');
|
||||
await user.click(await screen.findByText('service.name asc'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ columnName: 'service.name', order: 'asc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import useDebounce from 'hooks/useDebounce';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
|
||||
@@ -25,7 +24,7 @@ export type UseOrderByFilterResult = {
|
||||
selectedValue: IOption[];
|
||||
aggregationOptions: IOption[];
|
||||
generateOptions: (options: IOption[]) => IOption[];
|
||||
createOptions: (data: BaseAutocompleteData[]) => IOption[];
|
||||
createOptions: (keys: string[]) => IOption[];
|
||||
handleChange: (values: IOption[]) => void;
|
||||
handleSearchKeys: (search: string) => void;
|
||||
};
|
||||
@@ -121,7 +120,7 @@ export const useOrderByFilter = ({
|
||||
};
|
||||
|
||||
const createOptions = useCallback(
|
||||
(data: BaseAutocompleteData[]): IOption[] => mapLabelValuePairs(data).flat(),
|
||||
(keys: string[]): IOption[] => mapLabelValuePairs(keys).flat(),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
OrderByPayload,
|
||||
@@ -34,11 +33,8 @@ export const transformToOrderByStringValues = (
|
||||
return prepareSelectedValue;
|
||||
};
|
||||
|
||||
export function mapLabelValuePairs(
|
||||
arr: BaseAutocompleteData[],
|
||||
): Array<IOption>[] {
|
||||
return arr.map((item) => {
|
||||
const value = item.key;
|
||||
export function mapLabelValuePairs(keys: string[]): Array<IOption>[] {
|
||||
return keys.map((value) => {
|
||||
return [
|
||||
{
|
||||
label: `${value} ${ORDERBY_FILTERS.ASC}`,
|
||||
|
||||
@@ -3,7 +3,12 @@ import { HAVING_FILTER_REGEXP } from 'constants/regExp';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import uniqWith from 'lodash-es/unionWith';
|
||||
import { parse } from 'papaparse';
|
||||
import { HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
HavingForm,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { ORDERBY_FILTERS } from './OrderByFilter/config';
|
||||
import {
|
||||
@@ -111,3 +116,17 @@ export const filterByOperatorConfig = (
|
||||
OPERATORS_CONFIG[key].includes(option.label),
|
||||
);
|
||||
};
|
||||
|
||||
export const getMetricNameForQuery = (
|
||||
query: IBuilderQuery,
|
||||
): string | undefined => {
|
||||
if (query.dataSource !== DataSource.METRICS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
55
frontend/src/container/SavedViews/DeleteSavedViewDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Check, Trash2, X } from '@signozhq/icons';
|
||||
import { AlertDialog } from '@signozhq/ui/alert-dialog';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { DeleteSavedViewDialogProps } from './types';
|
||||
|
||||
function DeleteSavedViewDialog({
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteSavedViewDialogProps): JSX.Element {
|
||||
return (
|
||||
<AlertDialog
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
width="narrow"
|
||||
title="Delete this view"
|
||||
titleIcon={<Trash2 size={16} />}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
prefix={<X size={12} />}
|
||||
testId="delete-saved-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={onConfirm}
|
||||
prefix={<Check size={12} />}
|
||||
testId="delete-saved-view-confirm"
|
||||
>
|
||||
Delete view
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Text>
|
||||
Deleting this view is irreversible and cannot be undone.
|
||||
</Typography.Text>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteSavedViewDialog;
|
||||
@@ -14,11 +14,12 @@ const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
initialName = '',
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [name, setName] = useState(initialName);
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { highlightQueryExpression } from './utils/highlightQueryExpression';
|
||||
|
||||
function SavedViewHighlightedExpression({
|
||||
expression,
|
||||
}: {
|
||||
expression: string;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const parts = useMemo(
|
||||
() => highlightQueryExpression(expression, isDarkMode),
|
||||
[expression, isDarkMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map(({ from, text, color }) =>
|
||||
color ? (
|
||||
<span key={from} style={{ color }}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
text
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHighlightedExpression;
|
||||
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
110
frontend/src/container/SavedViews/SavedViewHoverCard.module.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
.anchor {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
--popover-padding: 0;
|
||||
--popover-background: var(--l1-background);
|
||||
--popover-border-color: var(--l2-border);
|
||||
--popover-border-radius: var(--radius-2);
|
||||
--popover-foreground: var(--l1-foreground);
|
||||
|
||||
width: 324px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.query {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.queryItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.queryName {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
.metric {
|
||||
color: var(--l2-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.expression {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.noFilters {
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.updated {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.metaText {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
130
frontend/src/container/SavedViews/SavedViewHoverCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { CalendarClock } from '@signozhq/icons';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { SAVED_VIEW_UPDATED_AT_FORMAT } from './constants';
|
||||
import SavedViewHighlightedExpression from './SavedViewHighlightedExpression';
|
||||
import { getSavedViewQuerySummaries } from './utils/getSavedViewQuerySummaries';
|
||||
import { SavedViewHoverCardProps } from './types';
|
||||
|
||||
import styles from './SavedViewHoverCard.module.scss';
|
||||
|
||||
function SavedViewHoverCard({
|
||||
target,
|
||||
onCardEnter,
|
||||
onCardLeave,
|
||||
onClose,
|
||||
}: SavedViewHoverCardProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const view = target?.view;
|
||||
const querySummaries = useMemo(
|
||||
() => (view ? getSavedViewQuerySummaries(view) : []),
|
||||
[view],
|
||||
);
|
||||
const hasQueryNames = querySummaries.length > 1;
|
||||
const copyValue = querySummaries
|
||||
.filter(({ expression }) => expression.length > 0)
|
||||
.map(({ queryName, expression }) =>
|
||||
hasQueryNames ? `${queryName}: ${expression}` : expression,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!view}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={styles.anchor}
|
||||
style={{ top: target?.anchorTop ?? 0, height: target?.anchorHeight ?? 0 }}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={styles.card}
|
||||
onOpenAutoFocus={(event): void => event.preventDefault()}
|
||||
onCloseAutoFocus={(event): void => event.preventDefault()}
|
||||
>
|
||||
{view && (
|
||||
<div
|
||||
className={styles.content}
|
||||
onMouseEnter={onCardEnter}
|
||||
onMouseLeave={onCardLeave}
|
||||
data-testid="saved-view-hover-card"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.name}>
|
||||
{view.spec.displayName}
|
||||
</Typography.Text>
|
||||
{copyValue && (
|
||||
<CopyButton
|
||||
value={copyValue}
|
||||
ariaLabel="Copy query"
|
||||
testId="saved-view-hover-card-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.query} data-testid="saved-view-hover-card-query">
|
||||
{querySummaries.length === 0 ? (
|
||||
<Typography.Text className={styles.noFilters}>
|
||||
No filters
|
||||
</Typography.Text>
|
||||
) : (
|
||||
querySummaries.map(({ queryName, expression, metric }) => (
|
||||
<div key={queryName} className={styles.queryItem}>
|
||||
{hasQueryNames && (
|
||||
<Typography.Text className={styles.queryName}>
|
||||
{queryName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{metric && (
|
||||
<Typography.Text className={styles.metric}>{metric}</Typography.Text>
|
||||
)}
|
||||
{expression && (
|
||||
<Typography.Text className={styles.expression}>
|
||||
<SavedViewHighlightedExpression expression={expression} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.updated}>
|
||||
<Typography.Text className={styles.sectionTitle}>
|
||||
Last updated
|
||||
</Typography.Text>
|
||||
{view.updatedBy && (
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{view.updatedBy}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{view.updatedAt && (
|
||||
<div className={styles.meta}>
|
||||
<CalendarClock size={14} />
|
||||
<Typography.Text className={styles.metaText}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
view.updatedAt,
|
||||
SAVED_VIEW_UPDATED_AT_FORMAT,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewHoverCard;
|
||||
56
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
56
frontend/src/container/SavedViews/SavedViewsEmptyState.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SavedViewsEmptyStateProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsEmptyState({
|
||||
isSearching,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: SavedViewsEmptyStateProps): JSX.Element {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
if (isSearching) {
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-no-results">
|
||||
<Typography.Text className={styles.stateText}>
|
||||
No views match your search
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-empty">
|
||||
<Typography.Text className={styles.stateTitle}>No views yet</Typography.Text>
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Save the current view to open it later.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={(): void => setIsModalOpen(true)}
|
||||
data-testid="saved-views-list-create"
|
||||
>
|
||||
Create view
|
||||
</Button>
|
||||
{isModalOpen && (
|
||||
<SaveViewModal
|
||||
mode="create"
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsModalOpen(false)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsEmptyState;
|
||||
@@ -15,6 +15,22 @@
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-error-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.viewTitle {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -32,6 +48,11 @@
|
||||
background: var(--callout-warning-background);
|
||||
color: var(--callout-warning-title);
|
||||
}
|
||||
|
||||
&.isError {
|
||||
background: var(--callout-error-background);
|
||||
color: var(--callout-error-title);
|
||||
}
|
||||
}
|
||||
|
||||
.nameText {
|
||||
@@ -59,3 +80,7 @@
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
rotate: 270deg;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { ArrowUpToLine, List, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
@@ -19,31 +24,21 @@ import styles from './SavedViewsHeader.module.scss';
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
onCollapse,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
|
||||
const { view, isLoading, isError, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
const id = await createView(displayName);
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
toast.success('You have created a new view.', {
|
||||
position: SAVED_VIEW_TOAST_POSITION,
|
||||
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[createView, onOpenViews],
|
||||
);
|
||||
const handleSave = useSaveNewView(createView);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
@@ -54,33 +49,51 @@ function SavedViewsHeader({
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
const isDirty = !!view && hasUnsavedChanges;
|
||||
const name = isError
|
||||
? SAVED_VIEW_LOAD_FAILED_NAME
|
||||
: (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.header, { [styles.isDirty]: isDirty })}
|
||||
className={cx(styles.header, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title={name}>
|
||||
<span
|
||||
className={cx(styles.name, { [styles.isDirty]: isDirty })}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<div className={styles.viewTitle}>
|
||||
{!isDirty && onOpenViews && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<List size={14} />}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title={name}>
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!view && (
|
||||
@@ -92,7 +105,7 @@ function SavedViewsHeader({
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
)}
|
||||
{view && !isDirty && (
|
||||
{(isError || (view && !isDirty)) && (
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
@@ -119,12 +132,12 @@ function SavedViewsHeader({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDirty && onOpenViews && (
|
||||
{onCollapse && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
title="Collapse Filters"
|
||||
icon={<ArrowUpToLine size={14} className={styles.collapseIcon} />}
|
||||
onClick={onCollapse}
|
||||
testId="saved-views-collapse"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
132
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
132
frontend/src/container/SavedViews/SavedViewsList.module.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
.list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: var(--spacing-6);
|
||||
|
||||
&:first-child:not(:last-child) {
|
||||
flex-shrink: 0;
|
||||
max-height: 40%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: var(--spacing-16);
|
||||
padding-right: var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
&.isActive {
|
||||
background-color: var(--l3-background);
|
||||
|
||||
.rowName {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rowMenu {
|
||||
display: flex;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.row:hover .rowMenu,
|
||||
.row:focus-within .rowMenu,
|
||||
.rowMenu:has([data-state='open']) {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rowButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--spacing-12);
|
||||
height: var(--spacing-12);
|
||||
padding: var(--spacing-1);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
--button-height: 100%;
|
||||
--button-padding: 0 var(--spacing-6);
|
||||
--button-justify-content: flex-start;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowNameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stateTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.stateText {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
200
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
200
frontend/src/container/SavedViews/SavedViewsList.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
|
||||
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
|
||||
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
|
||||
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
|
||||
import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewsList } from './hooks/useSavedViewsList';
|
||||
import SavedViewHoverCard from './SavedViewHoverCard';
|
||||
import SavedViewsEmptyState from './SavedViewsEmptyState';
|
||||
import SavedViewsListSection from './SavedViewsListSection';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import {
|
||||
PendingSavedViewAction,
|
||||
SavedViewRowActionKey,
|
||||
SavedViewsListRowHoverProps,
|
||||
} from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsList({
|
||||
source,
|
||||
search,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
useSavedViewRowActions(source);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<PendingSavedViewAction | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const hoverCard = useSavedViewHoverCard();
|
||||
const {
|
||||
onRowEnter: onHoverCardRowEnter,
|
||||
onRowLeave: onHoverCardRowLeave,
|
||||
close: closeHoverCard,
|
||||
} = hoverCard;
|
||||
|
||||
useEffect(() => {
|
||||
closeHoverCard();
|
||||
}, [search, closeHoverCard]);
|
||||
|
||||
const rowHover = useMemo<SavedViewsListRowHoverProps>(
|
||||
() => ({
|
||||
onRowEnter: (view, row): void => {
|
||||
const listTop = listRef.current?.getBoundingClientRect().top ?? 0;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
onHoverCardRowEnter({
|
||||
view,
|
||||
anchorTop: rowRect.top - listTop,
|
||||
anchorHeight: rowRect.height,
|
||||
});
|
||||
},
|
||||
onRowLeave: onHoverCardRowLeave,
|
||||
onMenuOpen: closeHoverCard,
|
||||
}),
|
||||
[onHoverCardRowEnter, onHoverCardRowLeave, closeHoverCard],
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO, key: SavedViewRowActionKey): void => {
|
||||
if (key === 'copyLink') {
|
||||
copyViewLink(view);
|
||||
return;
|
||||
}
|
||||
closeHoverCard();
|
||||
setPendingAction({ key, view });
|
||||
},
|
||||
[copyViewLink, closeHoverCard],
|
||||
);
|
||||
|
||||
const closePendingAction = useCallback((): void => setPendingAction(null), []);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!pendingAction || !(await deleteView(pendingAction.view))) {
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
clearView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loading} data-testid="saved-views-list-loading">
|
||||
{Array.from({ length: SAVED_VIEWS_LIST_LOADING_ROWS }, (_, index) => (
|
||||
<Skeleton.Input key={index} active size="small" block />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={styles.state} data-testid="saved-views-list-error">
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Could not load views
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
data-testid="saved-views-list-retry"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (createdByMe.length === 0 && createdByOthers.length === 0) {
|
||||
return (
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={styles.list}
|
||||
onScrollCapture={closeHoverCard}
|
||||
data-testid="saved-views-list"
|
||||
>
|
||||
{createdByMe.length > 0 && (
|
||||
<SavedViewsListSection
|
||||
title="Created by me"
|
||||
views={createdByMe}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
testId="saved-views-created-by-me"
|
||||
/>
|
||||
)}
|
||||
{createdByOthers.length > 0 && (
|
||||
<SavedViewsListSection
|
||||
title="Created by others"
|
||||
views={createdByOthers}
|
||||
activeViewId={viewKey}
|
||||
onSelect={selectView}
|
||||
onClear={clearView}
|
||||
onAction={handleAction}
|
||||
hover={rowHover}
|
||||
testId="saved-views-created-by-others"
|
||||
/>
|
||||
)}
|
||||
<SavedViewHoverCard
|
||||
target={hoverCard.target}
|
||||
onCardEnter={hoverCard.onCardEnter}
|
||||
onCardLeave={hoverCard.onCardLeave}
|
||||
onClose={closeHoverCard}
|
||||
/>
|
||||
{pendingAction?.key === 'editDetails' && (
|
||||
<SaveViewModal
|
||||
mode="edit"
|
||||
initialName={pendingAction.view.spec.displayName}
|
||||
isSaving={isRenaming}
|
||||
onClose={closePendingAction}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
renameView(pendingAction.view, displayName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{pendingAction?.key === 'delete' && (
|
||||
<DeleteSavedViewDialog
|
||||
isDeleting={isDeleting}
|
||||
onCancel={closePendingAction}
|
||||
onConfirm={(): void => {
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsList;
|
||||
61
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
61
frontend/src/container/SavedViews/SavedViewsListRow.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
import { SavedViewsListRowProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsListRow({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
onClear,
|
||||
onAction,
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.rowName}
|
||||
onClick={(): void => {
|
||||
if (!isActive) {
|
||||
onSelect(view);
|
||||
}
|
||||
}}
|
||||
data-testid="saved-views-row-name"
|
||||
>
|
||||
<span className={styles.rowNameText}>{name}</span>
|
||||
</Button>
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
icon={<Undo2 size={14} />}
|
||||
label="Clear view"
|
||||
tooltip="Clear view"
|
||||
onClick={onClear}
|
||||
testId="saved-views-row-clear"
|
||||
/>
|
||||
)}
|
||||
<span className={styles.rowMenu}>
|
||||
<SavedViewsRowMenu
|
||||
onAction={(key): void => onAction(view, key)}
|
||||
onOpen={hover.onMenuOpen}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsListRow;
|
||||
43
frontend/src/container/SavedViews/SavedViewsListSection.tsx
Normal file
43
frontend/src/container/SavedViews/SavedViewsListSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import SavedViewsListRow from './SavedViewsListRow';
|
||||
import { SavedViewsListSectionProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
function SavedViewsListSection({
|
||||
title,
|
||||
views,
|
||||
activeViewId,
|
||||
onSelect,
|
||||
onClear,
|
||||
onAction,
|
||||
hover,
|
||||
testId,
|
||||
}: SavedViewsListSectionProps): JSX.Element {
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
<Typography.Text className={styles.sectionTitle}>{title}</Typography.Text>
|
||||
<div className={styles.sectionList}>
|
||||
<OverlayScrollbar>
|
||||
<div>
|
||||
{views.map((view) => (
|
||||
<SavedViewsListRow
|
||||
key={view.id}
|
||||
view={view}
|
||||
isActive={view.id === activeViewId}
|
||||
onSelect={onSelect}
|
||||
onClear={onClear}
|
||||
onAction={onAction}
|
||||
hover={hover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsListSection;
|
||||
@@ -10,12 +10,34 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-height: var(--spacing-20);
|
||||
--input-border-radius: 0;
|
||||
--input-border-color: transparent;
|
||||
--input-hover-border-color: transparent;
|
||||
--input-focus-border-color: transparent;
|
||||
--input-focus-outline-width: 0;
|
||||
--input-background: var(--l1-background);
|
||||
--input-hover-background: var(--l1-background);
|
||||
--input-focus-background: var(--l1-background);
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.titleText {
|
||||
color: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useState } from 'react';
|
||||
import { TowerControl, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
|
||||
import styles from './SavedViewsPanel.module.scss';
|
||||
|
||||
function SavedViewsPanel({
|
||||
@@ -13,26 +17,38 @@ function SavedViewsPanel({
|
||||
// Absent when the list is always on screen.
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-testid="saved-views-panel"
|
||||
data-source={source}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.title}>All views</Typography.Text>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
{onClose && (
|
||||
<div className={styles.header} data-testid="saved-views-panel-header">
|
||||
<div className={styles.title}>
|
||||
<TowerControl size={16} />
|
||||
<Typography.Text className={styles.titleText}>All views</Typography.Text>
|
||||
</div>
|
||||
<SavedViewsIconButton
|
||||
title="Close"
|
||||
icon={<X size={14} />}
|
||||
onClick={onClose}
|
||||
data-testid="saved-views-close"
|
||||
testId="saved-views-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
frontend/src/container/SavedViews/SavedViewsRowButton.tsx
Normal file
40
frontend/src/container/SavedViews/SavedViewsRowButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SavedViewsRowButtonProps } from './types';
|
||||
|
||||
import styles from './SavedViewsList.module.scss';
|
||||
|
||||
// forwardRef and rest props so a menu can use it as its trigger.
|
||||
const SavedViewsRowButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SavedViewsRowButtonProps
|
||||
>(function SavedViewsRowButton(
|
||||
{ icon, label, tooltip, testId, onClick, ...triggerProps },
|
||||
ref,
|
||||
): JSX.Element {
|
||||
const button = (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={styles.rowButton}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<TooltipSimple title={tooltip}>{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
});
|
||||
|
||||
export default SavedViewsRowButton;
|
||||
44
frontend/src/container/SavedViews/SavedViewsRowMenu.tsx
Normal file
44
frontend/src/container/SavedViews/SavedViewsRowMenu.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import { SavedViewsRowMenuProps } from './types';
|
||||
|
||||
function SavedViewsRowMenu({
|
||||
onAction,
|
||||
onOpen,
|
||||
}: SavedViewsRowMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(
|
||||
() =>
|
||||
SAVED_VIEW_ROW_ACTIONS.flatMap(
|
||||
({ key, label, icon: Icon, danger, hasDividerBefore }): MenuItem[] => [
|
||||
...(hasDividerBefore ? [{ type: 'divider' as const }] : []),
|
||||
{
|
||||
key,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
danger,
|
||||
onClick: (): void => onAction(key),
|
||||
},
|
||||
],
|
||||
),
|
||||
[onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} side="right" align="start">
|
||||
<SavedViewsRowButton
|
||||
icon={<Ellipsis size={14} />}
|
||||
label="More options"
|
||||
tooltip="More options"
|
||||
testId="saved-views-row-menu"
|
||||
onPointerDown={onOpen}
|
||||
onKeyDown={onOpen}
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsRowMenu;
|
||||
@@ -0,0 +1,263 @@
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
function twoQueries(): Query {
|
||||
const base = queryWith(DataSource.TRACES, 'has_error = true');
|
||||
const [first] = base.builder.queryData;
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
first,
|
||||
{
|
||||
...first,
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
filter: { expression: 'duration_nano > 1000' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metricsQuery(): Query {
|
||||
const base = initialQueriesMap[DataSource.METRICS];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression: '' },
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'app.requests',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
const errors = makeView({
|
||||
id: 'view-1',
|
||||
displayName: 'Errors',
|
||||
updatedBy: 'editor@signoz.io',
|
||||
updatedAt: '2026-09-28T12:00:00Z',
|
||||
});
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
});
|
||||
const unfiltered = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'Everything',
|
||||
expression: '',
|
||||
});
|
||||
const combined = makeView({
|
||||
id: 'view-4',
|
||||
displayName: 'Errors and slow',
|
||||
query: twoQueries(),
|
||||
});
|
||||
const requestsMetric = makeView({
|
||||
id: 'view-5',
|
||||
displayName: 'Request rate',
|
||||
source: SavedviewtypesSourceDTO.metrics,
|
||||
dataSource: DataSource.METRICS,
|
||||
query: metricsQuery(),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
async function renderPanel(
|
||||
source: SavedviewtypesSourceDTO = SavedviewtypesSourceDTO.traces,
|
||||
dataSource: DataSource = DataSource.TRACES,
|
||||
): Promise<void> {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel source={source} onClose={jest.fn()} />,
|
||||
explorerUrl(ROUTES.TRACES_EXPLORER, {}),
|
||||
dataSource,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
jest.useFakeTimers();
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const card = (): HTMLElement | null =>
|
||||
screen.queryByTestId('saved-view-hover-card');
|
||||
|
||||
function advance(ms: number): void {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
}
|
||||
|
||||
function openCard(name: string): void {
|
||||
fireEvent.mouseEnter(row(name));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
}
|
||||
|
||||
describe('SavedViewHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
mockSavedViewsApi([errors, slow, unfiltered, combined, requestsMetric]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('opening and closing', () => {
|
||||
it('opens for a row after the delay, not before', async () => {
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS - 1);
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(1);
|
||||
expect(card()).toHaveTextContent('Errors');
|
||||
});
|
||||
|
||||
it('does not open when the pointer leaves before the delay', async () => {
|
||||
await renderPanel();
|
||||
|
||||
fireEvent.mouseEnter(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('stays open while the pointer moves into the card', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(card() as HTMLElement);
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS * 2);
|
||||
expect(card()).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(card() as HTMLElement);
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes on leaving the row and reopens for the next row after the delay', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.mouseLeave(row('Errors'));
|
||||
fireEvent.mouseEnter(row('Slow spans'));
|
||||
advance(SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS);
|
||||
expect(card()).toBeNull();
|
||||
|
||||
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
expect(card()).toHaveTextContent('Slow spans');
|
||||
});
|
||||
|
||||
it('closes when the row menu is pressed', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.pointerDown(
|
||||
within(row('Errors')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
|
||||
expect(card()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content', () => {
|
||||
it('shows the filter, the updater and the update time', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
const content = card() as HTMLElement;
|
||||
expect(
|
||||
within(content).getByTestId('saved-view-hover-card-query'),
|
||||
).toHaveTextContent('has_error = true');
|
||||
expect(content).toHaveTextContent('editor@signoz.io');
|
||||
expect(content).toHaveTextContent('Sep 28, 2026');
|
||||
});
|
||||
|
||||
it('copies the filter expression', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors');
|
||||
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('has_error = true');
|
||||
});
|
||||
|
||||
it('names each query when there are several, and copies them per line', async () => {
|
||||
await renderPanel();
|
||||
openCard('Errors and slow');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('A');
|
||||
expect(query).toHaveTextContent('B');
|
||||
fireEvent.click(screen.getByTestId('saved-view-hover-card-copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'A: has_error = true\nB: duration_nano > 1000',
|
||||
);
|
||||
});
|
||||
|
||||
it('says there are no filters, with nothing to copy', async () => {
|
||||
await renderPanel();
|
||||
openCard('Everything');
|
||||
|
||||
expect(screen.getByTestId('saved-view-hover-card-query')).toHaveTextContent(
|
||||
'No filters',
|
||||
);
|
||||
expect(screen.queryByTestId('saved-view-hover-card-copy')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the metric of a metrics query', async () => {
|
||||
await renderPanel(SavedviewtypesSourceDTO.metrics, DataSource.METRICS);
|
||||
openCard('Request rate');
|
||||
|
||||
const query = screen.getByTestId('saved-view-hover-card-query');
|
||||
expect(query).toHaveTextContent('app.requests · rate · sum');
|
||||
expect(query).not.toHaveTextContent('No filters');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const baseView = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const errors = {
|
||||
...baseView,
|
||||
spec: {
|
||||
...baseView.spec,
|
||||
selectedFields: [{ name: 'service.name' }, { name: 'name' }],
|
||||
},
|
||||
};
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const { traces } = usePreferenceContext();
|
||||
const [headerKey, setHeaderKey] = useState(0);
|
||||
const columns = traces.preferences?.columns ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SavedViewsHeader
|
||||
key={headerKey}
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
<span data-testid="columns">
|
||||
{columns.map((column) => column.name).join(',')}
|
||||
</span>
|
||||
<Button
|
||||
data-testid="add-column"
|
||||
onClick={(): void =>
|
||||
traces.updateColumns([...columns, { name: `extra-${columns.length}` }])
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
data-testid="remount-header"
|
||||
onClick={(): void => setHeaderKey((key) => key + 1)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const columnsText = (): string | null =>
|
||||
screen.getByTestId('columns').textContent;
|
||||
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
|
||||
|
||||
async function openView(): Promise<void> {
|
||||
renderWithExplorerProviders(<Harness />, viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader columns', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps edited columns when the view is fetched again', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openView();
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await userEvent.click(screen.getByTestId('remount-header'));
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
expect(columnsText()).toBe('service.name,name,extra-2');
|
||||
expect(isDirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('discard puts the saved columns back', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openView();
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('discard after an update goes back to the updated columns', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openView();
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Update selected view' }),
|
||||
);
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(columnsText()).toBe('service.name,name,extra-2'));
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsRestore from '../SavedViewsRestore';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsRestore source={SavedviewtypesSourceDTO.traces} />
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() => expect(chip()).toHaveTextContent(name));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader last used view', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reopens on a bare explorer', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a url with its own query alone', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const baseView = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const withColumns = {
|
||||
...baseView,
|
||||
spec: {
|
||||
...baseView.spec,
|
||||
selectedFields: [{ name: 'service.name' }, { name: 'duration_nano' }],
|
||||
},
|
||||
};
|
||||
|
||||
function renderHeader(url: string): void {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
|
||||
|
||||
describe('SavedViewsHeader when loading fails', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows the load failed state with create and clear when the view does not load', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(viewUrl(PATH, withColumns));
|
||||
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('View failed to load'));
|
||||
expect(isDirty()).toBe(false);
|
||||
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the view clean when only the views list fails', async () => {
|
||||
mockSavedViewsApi([withColumns], { listFailures: Number.POSITIVE_INFINITY });
|
||||
renderHeader(viewUrl(PATH, withColumns));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('Errors');
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,12 @@ import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
mockSavedViewsApi,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
@@ -112,7 +112,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
),
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'You have created a new view.',
|
||||
'View created',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
);
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
|
||||
@@ -15,12 +15,12 @@ import SavedViewsRestore from '../SavedViewsRestore';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
mockSavedViewsApi,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
@@ -130,16 +130,6 @@ describe('SavedViewsHeader', () => {
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to My view when the view in the url does not load', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
@@ -227,38 +217,6 @@ describe('SavedViewsHeader', () => {
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reopens on a bare explorer', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a url with its own query alone', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsaved changes indicator', () => {
|
||||
it('never flashes while switching between views already loaded', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const mine = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const alsoMine = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
const theirs = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'Checkout errors',
|
||||
createdBy: 'someone@signoz.io',
|
||||
});
|
||||
|
||||
function renderPanel(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const rowNames = (sectionTestId: string): string[] =>
|
||||
within(screen.getByTestId(sectionTestId))
|
||||
.getAllByTestId('saved-views-row-name')
|
||||
.map((row) => row.textContent ?? '');
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
describe('SavedViewsPanel list', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('splits the views into created by me and created by others', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine, theirs]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual([
|
||||
'Errors',
|
||||
'Slow spans',
|
||||
]);
|
||||
expect(rowNames('saved-views-created-by-others')).toStrictEqual([
|
||||
'Checkout errors',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides a section with no views', async () => {
|
||||
mockSavedViewsApi([mine]);
|
||||
renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-created-by-me');
|
||||
expect(screen.queryByTestId('saved-views-created-by-others')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens a view from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(alsoMine.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the open view and clears it from its row', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const activeRow = row('Errors');
|
||||
expect(within(activeRow).getByTestId('saved-views-row-clear')).toBeVisible();
|
||||
expect(
|
||||
within(row('Slow spans')).queryByTestId('saved-views-row-clear'),
|
||||
).toBeNull();
|
||||
|
||||
await userEvent.click(within(activeRow).getByTestId('saved-views-row-clear'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the url alone when the open view is clicked again', async () => {
|
||||
mockSavedViewsApi([mine]);
|
||||
const { history } = renderPanel(viewUrl(PATH, mine));
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const before = history.location.search;
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
|
||||
expect(history.location.search).toBe(before);
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('filters both sections by name, ignoring case', async () => {
|
||||
mockSavedViewsApi([mine, alsoMine, theirs]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
await userEvent.type(screen.getByTestId('saved-views-search'), 'ERRORS');
|
||||
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
expect(rowNames('saved-views-created-by-others')).toStrictEqual([
|
||||
'Checkout errors',
|
||||
]);
|
||||
});
|
||||
|
||||
it('says so when nothing matches, and recovers when cleared', async () => {
|
||||
mockSavedViewsApi([mine]);
|
||||
renderPanel();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
const search = screen.getByTestId('saved-views-search');
|
||||
|
||||
await userEvent.type(search, 'nothing like this');
|
||||
expect(
|
||||
screen.getByTestId('saved-views-list-no-results'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-list-create')).toBeNull();
|
||||
|
||||
await userEvent.clear(search);
|
||||
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers to create a view when there are none, and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
expect(toast.success).toHaveBeenCalledWith('View created', expect.anything());
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('saved-views-created-by-me'),
|
||||
).resolves.toHaveTextContent('First view');
|
||||
});
|
||||
|
||||
it('shows the list error and loads the list on retry', async () => {
|
||||
mockSavedViewsApi([mine], { listFailures: 1 });
|
||||
renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-retry'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-views-created-by-me'),
|
||||
).resolves.toHaveTextContent('Errors');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// copy-to-clipboard falls back to window.prompt in jsdom.
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
});
|
||||
|
||||
function renderPanelWithHeader(
|
||||
url = explorerUrl(PATH, {}),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsHeader source={SavedviewtypesSourceDTO.traces} />
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
async function chooseFromRowMenu(
|
||||
viewName: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row(viewName)).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
|
||||
describe('SavedViewsPanel row menu', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockCopy.mockClear();
|
||||
jest.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
it('offers copy link, edit details and delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
await userEvent.click(
|
||||
within(row('Errors')).getByTestId('saved-views-row-menu'),
|
||||
);
|
||||
|
||||
const items = await screen.findAllByRole('menuitem');
|
||||
expect(items.map((item) => item.textContent)).toStrictEqual([
|
||||
'Copy link',
|
||||
'Edit details',
|
||||
'Delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy link');
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
const link = new URL(mockCopy.mock.calls[0][0]);
|
||||
expect(link.pathname).toBe(PATH);
|
||||
expect(link.searchParams.get(QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(slow.id),
|
||||
);
|
||||
expect(link.searchParams.get(QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(slow.spec.panelType),
|
||||
);
|
||||
expect(
|
||||
decodeURIComponent(link.searchParams.get(QueryParams.compositeQuery) ?? ''),
|
||||
).toContain('duration_nano > 1000');
|
||||
expect(toast.success).toHaveBeenCalledWith('Link copied', expect.anything());
|
||||
});
|
||||
|
||||
it('renames a view without touching its query', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
expect(name).toHaveValue('Slow spans');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'Slow checkout spans');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const [update] = requests.updated;
|
||||
expect(update.id).toBe(slow.id);
|
||||
expect(update.body).toMatchObject({
|
||||
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('View updated', expect.anything());
|
||||
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows the new name in the header when the open view is renamed', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Edit details');
|
||||
const name = screen.getByTestId('save-view-name');
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, 'All errors');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('All errors'));
|
||||
});
|
||||
|
||||
it('asks before deleting and keeps the view on cancel', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
|
||||
expect(screen.getByText('Delete this view')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Deleting this view is irreversible and cannot be undone.'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-cancel'));
|
||||
|
||||
expect(requests.deleted).toHaveLength(0);
|
||||
expect(row('Slow spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a view on confirm', async () => {
|
||||
const requests = mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() => expect(requests.deleted).toStrictEqual([slow.id]));
|
||||
expect(toast.success).toHaveBeenCalledWith('View deleted', expect.anything());
|
||||
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
|
||||
});
|
||||
|
||||
it('clears the open view when it is deleted', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
|
||||
|
||||
await chooseFromRowMenu('Errors', 'Delete');
|
||||
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull(),
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
});
|
||||
});
|
||||
102
frontend/src/container/SavedViews/__tests__/savedViewsApiMock.ts
Normal file
102
frontend/src/container/SavedViews/__tests__/savedViewsApiMock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
export const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
export const TEST_USER_EMAIL = 'test@signoz.io';
|
||||
|
||||
export interface Requests {
|
||||
created: unknown[];
|
||||
updated: { id: string; body: unknown }[];
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
// Mutates `views` on create, update and delete, so a refetch sees the change.
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
listFailures = 0,
|
||||
}: {
|
||||
createStatus?: number;
|
||||
createdId?: string;
|
||||
getDelay?: number;
|
||||
listFailures?: number;
|
||||
} = {},
|
||||
): Requests {
|
||||
const requests: Requests = { created: [], updated: [], deleted: [] };
|
||||
let failedLists = 0;
|
||||
server.use(
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
if (failedLists < listFailures) {
|
||||
failedLists += 1;
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
const source = req.url.searchParams.get('source');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: views.filter((view) => !source || view.source === source),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
const view = views.find((v) => v.id === req.params.id);
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.delay(getDelay),
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: view }),
|
||||
);
|
||||
}),
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.created.push(body);
|
||||
if (createStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
views.push({
|
||||
...body,
|
||||
id: createdId,
|
||||
createdBy: TEST_USER_EMAIL,
|
||||
updatedBy: TEST_USER_EMAIL,
|
||||
} as SavedviewtypesSavedViewDTO);
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({ status: 'success', data: { id: createdId } }),
|
||||
);
|
||||
}),
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
const id = String(req.params.id);
|
||||
requests.updated.push({ id, body });
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views[index] = { ...views[index], spec: body.spec };
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
const id = String(req.params.id);
|
||||
requests.deleted.push(id);
|
||||
const index = views.findIndex((v) => v.id === id);
|
||||
if (index >= 0) {
|
||||
views.splice(index, 1);
|
||||
}
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
@@ -19,12 +19,11 @@ import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
@@ -35,8 +34,7 @@ import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchPa
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
|
||||
export const API = 'http://localhost/api/v2/saved_views';
|
||||
import { TEST_USER_EMAIL } from './savedViewsApiMock';
|
||||
|
||||
// The server stores queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
@@ -78,17 +76,25 @@ export function makeView({
|
||||
source = SavedviewtypesSourceDTO.traces,
|
||||
dataSource = DataSource.TRACES,
|
||||
expression = 'has_error = true',
|
||||
query = queryWith(dataSource, expression),
|
||||
panelType = PANEL_TYPES.LIST,
|
||||
createdBy = TEST_USER_EMAIL,
|
||||
updatedBy = createdBy,
|
||||
updatedAt,
|
||||
}: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
source?: SavedviewtypesSourceDTO;
|
||||
dataSource?: DataSource;
|
||||
expression?: string;
|
||||
query?: Query;
|
||||
panelType?: PANEL_TYPES;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
}): SavedviewtypesSavedViewDTO {
|
||||
const spec = toSavedViewSpec({
|
||||
query: queryWith(dataSource, expression),
|
||||
query,
|
||||
panelType,
|
||||
displayName,
|
||||
});
|
||||
@@ -97,8 +103,9 @@ export function makeView({
|
||||
name: `${displayName.toLowerCase()}-abc`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'test@signoz.io',
|
||||
updatedBy: 'test@signoz.io',
|
||||
createdBy,
|
||||
updatedBy,
|
||||
updatedAt,
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
@@ -153,69 +160,6 @@ export function viewUrl(
|
||||
});
|
||||
}
|
||||
|
||||
export interface Requests {
|
||||
created: unknown[];
|
||||
updated: { id: string; body: unknown }[];
|
||||
}
|
||||
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
|
||||
): Requests {
|
||||
const requests: Requests = { created: [], updated: [] };
|
||||
server.use(
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
const source = req.url.searchParams.get('source');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: views.filter((view) => !source || view.source === source),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
const view = views.find((v) => v.id === req.params.id);
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.delay(getDelay),
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: view }),
|
||||
);
|
||||
}),
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.created.push(body);
|
||||
if (createStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({ status: 'success', data: { id: createdId } }),
|
||||
);
|
||||
}),
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.updated.push({ id: String(req.params.id), body });
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
@@ -274,21 +218,29 @@ export function renderWithExplorerProviders(
|
||||
},
|
||||
});
|
||||
|
||||
const defaultAppContext = getAppContextMock('ADMIN');
|
||||
const appContext = {
|
||||
...defaultAppContext,
|
||||
user: { ...defaultAppContext.user, email: TEST_USER_EMAIL },
|
||||
};
|
||||
|
||||
const result = render(
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={mockStore(store.getState())}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN')}>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
<AppContext.Provider value={appContext}>
|
||||
<TimezoneProvider>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
</TimezoneProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SaveViewModalMode } from './types';
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
|
||||
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
|
||||
|
||||
@@ -27,9 +28,33 @@ export const SAVED_VIEW_URL_PARAMS = [
|
||||
|
||||
export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
|
||||
|
||||
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
|
||||
|
||||
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
|
||||
|
||||
// Long enough for the pointer to cross from the row into the card.
|
||||
export const SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS = 150;
|
||||
|
||||
export const SAVED_VIEW_UPDATED_AT_FORMAT = 'HH:mm:ss — MMM D, YYYY';
|
||||
|
||||
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
create: 'Create new view',
|
||||
saveAsNew: 'Save as new view',
|
||||
edit: 'Edit view details',
|
||||
};
|
||||
|
||||
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
|
||||
{ key: 'copyLink', label: 'Copy link', icon: Link },
|
||||
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
hasDividerBefore: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useActiveSavedView(
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo((): boolean => {
|
||||
if (!view) {
|
||||
if (!view || isError) {
|
||||
return false;
|
||||
}
|
||||
if (!isStagedQueryCurrent) {
|
||||
@@ -56,6 +56,7 @@ export function useActiveSavedView(
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isError,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
|
||||
22
frontend/src/container/SavedViews/hooks/useSaveNewView.ts
Normal file
22
frontend/src/container/SavedViews/hooks/useSaveNewView.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
|
||||
export function useSaveNewView(
|
||||
createView: UseSavedViewActionsResult['createView'],
|
||||
): (displayName: string) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
position: SAVED_VIEW_TOAST_POSITION,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[createView],
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateGetSavedView,
|
||||
invalidateListSavedViews,
|
||||
useCreateSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
@@ -21,6 +19,7 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
@@ -33,6 +32,8 @@ import {
|
||||
} from '../constants';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
@@ -54,6 +55,11 @@ export function useSavedViewActions(
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
const preferences = usePreferenceContext();
|
||||
const preferencesSlice =
|
||||
optionsDataSource === DataSource.TRACES
|
||||
? preferences.traces
|
||||
: preferences.logs;
|
||||
|
||||
const { mutateAsync: createSavedView, isLoading: isCreating } =
|
||||
useCreateSavedView();
|
||||
@@ -61,14 +67,7 @@ export function useSavedViewActions(
|
||||
useUpdateSavedView();
|
||||
|
||||
const invalidateViews = useCallback(
|
||||
async (id?: string): Promise<void> => {
|
||||
await Promise.all([
|
||||
invalidateListSavedViews(queryClient),
|
||||
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
|
||||
// The saved views bar still lists through v1.
|
||||
queryClient.invalidateQueries([{ sourcepage: source }]),
|
||||
]);
|
||||
},
|
||||
(id?: string): Promise<void> => invalidateSavedViews(queryClient, source, id),
|
||||
[queryClient, source],
|
||||
);
|
||||
|
||||
@@ -95,14 +94,21 @@ export function useSavedViewActions(
|
||||
[handleExplorerTabChange, setLastUsedView],
|
||||
);
|
||||
|
||||
// The preference provider re-reads a view's columns only when its list
|
||||
// changes, so the list is refetched as the view is applied again.
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
void invalidateListSavedViews(queryClient);
|
||||
if (optionsDataSource) {
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
optionsDataSource,
|
||||
);
|
||||
preferencesSlice.updateColumns(columns);
|
||||
if (formatting) {
|
||||
preferencesSlice.updateFormatting(formatting);
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
},
|
||||
[queryClient, selectView],
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
|
||||
SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS,
|
||||
} from '../constants';
|
||||
import {
|
||||
SavedViewHoverCardTarget,
|
||||
UseSavedViewHoverCardResult,
|
||||
} from '../types';
|
||||
|
||||
export function useSavedViewHoverCard(): UseSavedViewHoverCardResult {
|
||||
const [target, setTarget] = useState<SavedViewHoverCardTarget | null>(null);
|
||||
const openTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const cancelOpen = useCallback(
|
||||
(): void => clearTimeout(openTimer.current),
|
||||
[],
|
||||
);
|
||||
const cancelClose = useCallback(
|
||||
(): void => clearTimeout(closeTimer.current),
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleClose = useCallback((): void => {
|
||||
cancelClose();
|
||||
closeTimer.current = setTimeout(
|
||||
() => setTarget(null),
|
||||
SAVED_VIEW_HOVER_CARD_CLOSE_DELAY_MS,
|
||||
);
|
||||
}, [cancelClose]);
|
||||
|
||||
const onRowEnter = useCallback(
|
||||
(next: SavedViewHoverCardTarget): void => {
|
||||
cancelOpen();
|
||||
openTimer.current = setTimeout(() => {
|
||||
cancelClose();
|
||||
setTarget(next);
|
||||
}, SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
|
||||
},
|
||||
[cancelOpen, cancelClose],
|
||||
);
|
||||
|
||||
const onRowLeave = useCallback((): void => {
|
||||
cancelOpen();
|
||||
scheduleClose();
|
||||
}, [cancelOpen, scheduleClose]);
|
||||
|
||||
const close = useCallback((): void => {
|
||||
cancelOpen();
|
||||
cancelClose();
|
||||
setTarget(null);
|
||||
}, [cancelOpen, cancelClose]);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
cancelOpen();
|
||||
cancelClose();
|
||||
},
|
||||
[cancelOpen, cancelClose],
|
||||
);
|
||||
|
||||
return {
|
||||
target,
|
||||
onRowEnter,
|
||||
onRowLeave,
|
||||
onCardEnter: cancelClose,
|
||||
onCardLeave: scheduleClose,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
useDeleteSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'container/AIAssistant/components/ActionsSection/utils/openSavedView';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from '../constants';
|
||||
import { UseSavedViewRowActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
|
||||
|
||||
function showErrorToast(error: unknown, fallback: string): void {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
fallback,
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
}
|
||||
|
||||
export function useSavedViewRowActions(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseSavedViewRowActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
|
||||
useUpdateSavedView();
|
||||
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
|
||||
useDeleteSavedView();
|
||||
|
||||
const copyViewLink = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
const path = buildExplorerNavigationUrl(pathname, getSavedViewQuery(view), {
|
||||
[QueryParams.panelTypes]: view.spec.panelType,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
},
|
||||
[pathname, copyToClipboard],
|
||||
);
|
||||
|
||||
const renameView = useCallback(
|
||||
async (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: { ...view.spec, displayName },
|
||||
},
|
||||
});
|
||||
await invalidateSavedViews(queryClient, source, view.id);
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not update the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
// while the header still has it selected.
|
||||
const deleteView = useCallback(
|
||||
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
|
||||
try {
|
||||
await deleteSavedView({ pathParams: { id: view.id } });
|
||||
await invalidateSavedViews(queryClient, source);
|
||||
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorToast(error, 'Could not delete the view');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[deleteSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
|
||||
}
|
||||
35
frontend/src/container/SavedViews/hooks/useSavedViewsList.ts
Normal file
35
frontend/src/container/SavedViews/hooks/useSavedViewsList.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { UseSavedViewsListResult } from '../types';
|
||||
|
||||
export function useSavedViewsList(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
search: string,
|
||||
): UseSavedViewsListResult {
|
||||
const { user } = useAppContext();
|
||||
const { data, isLoading, isError, refetch } = useListSavedViews({ source });
|
||||
|
||||
const { createdByMe, createdByOthers } = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
const views = (data?.data ?? []).filter((view) =>
|
||||
view.spec.displayName.toLowerCase().includes(term),
|
||||
);
|
||||
return {
|
||||
createdByMe: views.filter((view) => view.createdBy === user.email),
|
||||
createdByOthers: views.filter((view) => view.createdBy !== user.email),
|
||||
};
|
||||
}, [data, search, user.email]);
|
||||
|
||||
return {
|
||||
createdByMe,
|
||||
createdByOthers,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch: (): void => {
|
||||
void refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { Link } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -57,10 +58,11 @@ export interface UseRestoreLastUsedViewArgs {
|
||||
selectView: UseSavedViewActionsResult['selectView'];
|
||||
}
|
||||
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew';
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew' | 'edit';
|
||||
|
||||
export interface SaveViewModalProps {
|
||||
mode: SaveViewModalMode;
|
||||
initialName?: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves true when saved; the modal stays open otherwise.
|
||||
@@ -81,3 +83,127 @@ export interface SavedViewsIconButtonProps {
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export interface UseSavedViewsListResult {
|
||||
createdByMe: SavedviewtypesSavedViewDTO[];
|
||||
createdByOthers: SavedviewtypesSavedViewDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsListSectionProps {
|
||||
title: string;
|
||||
views: SavedviewtypesSavedViewDTO[];
|
||||
activeViewId?: string;
|
||||
onSelect: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
onClear: () => void;
|
||||
onAction: SavedViewsListRowProps['onAction'];
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowProps {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
isActive: boolean;
|
||||
onSelect: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
onClear: () => void;
|
||||
onAction: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
key: SavedViewRowActionKey,
|
||||
) => void;
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
|
||||
|
||||
export interface SavedViewRowActionConfig {
|
||||
key: SavedViewRowActionKey;
|
||||
label: string;
|
||||
icon: typeof Link;
|
||||
danger?: boolean;
|
||||
hasDividerBefore?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingSavedViewAction {
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
export interface SavedViewsRowButtonProps {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
testId: string;
|
||||
onClick?: () => void;
|
||||
onPointerDown?: () => void;
|
||||
onKeyDown?: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsRowMenuProps {
|
||||
onAction: (key: SavedViewRowActionKey) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface DeleteSavedViewDialogProps {
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export interface UseSavedViewRowActionsResult {
|
||||
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
renameView: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
) => Promise<boolean>;
|
||||
deleteView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isRenaming: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardTarget {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}
|
||||
|
||||
export interface UseSavedViewHoverCardResult {
|
||||
target: SavedViewHoverCardTarget | null;
|
||||
onRowEnter: (target: SavedViewHoverCardTarget) => void;
|
||||
onRowLeave: () => void;
|
||||
onCardEnter: () => void;
|
||||
onCardLeave: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewHoverCardProps {
|
||||
target: SavedViewHoverCardTarget | null;
|
||||
onCardEnter: () => void;
|
||||
onCardLeave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsListRowHoverProps {
|
||||
onRowEnter: (view: SavedviewtypesSavedViewDTO, row: HTMLElement) => void;
|
||||
onRowLeave: () => void;
|
||||
onMenuOpen: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewQuerySummary {
|
||||
queryName: string;
|
||||
expression: string;
|
||||
metric?: string;
|
||||
}
|
||||
|
||||
export interface HighlightedQueryPart {
|
||||
from: number;
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewsEmptyStateProps {
|
||||
isSearching: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: (displayName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,169 @@ describe('hasUnsavedViewChanges', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores fields the save path never writes, kept by older views', () => {
|
||||
const metricsView = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'Old metrics view',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = metricsView.spec.queries;
|
||||
const olderView = {
|
||||
...metricsView,
|
||||
spec: {
|
||||
...metricsView.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
aggregations: [
|
||||
{ metricName: 'erlang_vm_msacc_sleep_seconds_total', reduceTo: 'avg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: olderView,
|
||||
stagedQuery: stagedFrom(olderView, DataSource.METRICS),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a group-by stored as attribute and staged as tag as the same field', () => {
|
||||
const attributeView = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.METRICS, ''),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'By os',
|
||||
}),
|
||||
);
|
||||
const [storedQuery] = attributeView.spec.queries;
|
||||
const storedView = {
|
||||
...attributeView,
|
||||
spec: {
|
||||
...attributeView.spec,
|
||||
queries: [
|
||||
{
|
||||
...storedQuery,
|
||||
spec: {
|
||||
...storedQuery.spec,
|
||||
groupBy: [
|
||||
{ name: 'os.type', fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
const opened = stagedFrom(storedView, DataSource.METRICS);
|
||||
const stagedWithTag: Query = {
|
||||
...opened,
|
||||
builder: {
|
||||
...opened.builder,
|
||||
queryData: opened.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
groupBy: [{ key: 'os.type', dataType: 'string', type: 'tag' }],
|
||||
})),
|
||||
},
|
||||
} as Query;
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: storedView,
|
||||
stagedQuery: stagedWithTag,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('list order', () => {
|
||||
const withOrderBy = (
|
||||
staged: Query,
|
||||
orderBy: Query['builder']['queryData'][number]['orderBy'],
|
||||
): Query => ({
|
||||
...staged,
|
||||
builder: {
|
||||
...staged.builder,
|
||||
queryData: staged.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
orderBy,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const listView = (
|
||||
orderBy: Query['builder']['queryData'][number]['orderBy'],
|
||||
) =>
|
||||
viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: withOrderBy(query(DataSource.LOGS), orderBy),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
it('opens a view saved with no order clean', () => {
|
||||
const noOrder = listView([]);
|
||||
|
||||
expect(noOrder.spec.queries[0].spec).not.toHaveProperty('order');
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: noOrder,
|
||||
stagedQuery: stagedFrom(noOrder, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty once the sort changes, and clean again after saving it', () => {
|
||||
const noOrder = listView([]);
|
||||
const sorted = withOrderBy(stagedFrom(noOrder, DataSource.LOGS), [
|
||||
{ columnName: 'timestamp', order: 'asc' },
|
||||
]);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: noOrder,
|
||||
stagedQuery: sorted,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const saved = listView([{ columnName: 'timestamp', order: 'asc' }]);
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: saved,
|
||||
stagedQuery: stagedFrom(saved, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('opens a legacy view sorted by timestamp and id clean', () => {
|
||||
const legacy = listView([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
{ columnName: 'id', order: 'desc' },
|
||||
]);
|
||||
|
||||
expect(legacy.spec.queries[0].spec).toHaveProperty('order.length', 2);
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: legacy,
|
||||
stagedQuery: stagedFrom(legacy, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores formatting on traces', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
|
||||
import { highlightQueryExpression } from '../highlightQueryExpression';
|
||||
|
||||
const EXPRESSION =
|
||||
"service.name = 'frontend' AND duration_nano > 100 AND status IN ['ok', 'error']";
|
||||
|
||||
function stringColor(styles: typeof copilotDarkStyle): string | undefined {
|
||||
return (styles ?? []).find(({ tag }) =>
|
||||
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
|
||||
)?.color;
|
||||
}
|
||||
|
||||
describe('highlightQueryExpression', () => {
|
||||
it.each([
|
||||
['dark', true],
|
||||
['light', false],
|
||||
])('keeps every character of the expression in %s mode', (_, isDarkMode) => {
|
||||
const parts = highlightQueryExpression(EXPRESSION, isDarkMode);
|
||||
|
||||
expect(parts.map(({ text }) => text).join('')).toBe(EXPRESSION);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dark', true, copilotDarkStyle],
|
||||
['light', false, githubLightStyle],
|
||||
])(
|
||||
'colours string values like the %s query search theme',
|
||||
(_, isDarkMode, styles) => {
|
||||
const parts = highlightQueryExpression(EXPRESSION, isDarkMode);
|
||||
|
||||
expect(parts.find(({ text }) => text === "'frontend'")?.color).toBe(
|
||||
stringColor(styles),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns nothing for an empty expression', () => {
|
||||
expect(highlightQueryExpression('', true)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewQuerySummary } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
|
||||
export function getSavedViewQuerySummaries(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedViewQuerySummary[] {
|
||||
return getSavedViewQuery(view)
|
||||
.builder.queryData.map((query): SavedViewQuerySummary => {
|
||||
const aggregation = query.aggregations?.[0] as MetricAggregation | undefined;
|
||||
return {
|
||||
queryName: query.queryName,
|
||||
expression: convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters ?? { items: [], op: 'AND' },
|
||||
query.filter?.expression,
|
||||
).filter.expression.trim(),
|
||||
metric:
|
||||
query.dataSource === DataSource.METRICS && aggregation?.metricName
|
||||
? [
|
||||
aggregation.metricName,
|
||||
aggregation.timeAggregation,
|
||||
aggregation.spaceAggregation,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter(({ expression, metric }) => expression.length > 0 || !!metric);
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { getSavedViewQuery } from './getSavedViewQuery';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
|
||||
// Legacy context names the server stores under their v5 name.
|
||||
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
|
||||
tag: TelemetrytypesFieldContextDTO.attribute,
|
||||
point: TelemetrytypesFieldContextDTO.attribute,
|
||||
spanfield: TelemetrytypesFieldContextDTO.span,
|
||||
logfield: TelemetrytypesFieldContextDTO.log,
|
||||
tracefield: TelemetrytypesFieldContextDTO.trace,
|
||||
};
|
||||
|
||||
// The server drops empty values when it stores a query, while the query
|
||||
// builder fills them with defaults, so both sides lose them before comparing.
|
||||
function withoutEmptyValues(value: unknown): unknown {
|
||||
@@ -17,7 +28,12 @@ function withoutEmptyValues(value: unknown): unknown {
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
|
||||
.map(([key, item]) => {
|
||||
if (key === 'fieldContext' && typeof item === 'string') {
|
||||
return [key, FIELD_CONTEXT_ALIASES[item] ?? item] as const;
|
||||
}
|
||||
return [key, withoutEmptyValues(item)] as const;
|
||||
})
|
||||
.filter(([, item]) => item !== undefined);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
@@ -27,8 +43,8 @@ function withoutEmptyValues(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
|
||||
// side gets different defaults.
|
||||
// Both sides go through the v5 mapper, so fields it never writes (reduceTo on
|
||||
// a graph in older views) drop out; the builder's shape gets different defaults.
|
||||
export function hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
@@ -42,15 +58,16 @@ export function hasUnsavedViewChanges({
|
||||
return true;
|
||||
}
|
||||
|
||||
const { queries: storedQueries } = mapCompositeQueryFromQuery(
|
||||
getSavedViewQuery(view),
|
||||
panelType,
|
||||
);
|
||||
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
|
||||
stagedQuery,
|
||||
panelType,
|
||||
);
|
||||
if (
|
||||
!isEqual(
|
||||
withoutEmptyValues(view.spec.queries),
|
||||
withoutEmptyValues(stagedQueries),
|
||||
)
|
||||
!isEqual(withoutEmptyValues(storedQueries), withoutEmptyValues(stagedQueries))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { Highlighter, highlightTree, Tag } from '@lezer/highlight';
|
||||
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLightStyle } from '@uiw/codemirror-theme-github';
|
||||
|
||||
import { HighlightedQueryPart } from '../types';
|
||||
|
||||
function createThemeHighlighter(styles: typeof copilotDarkStyle): Highlighter {
|
||||
const colorByTag = new Map<Tag, string>();
|
||||
(styles ?? []).forEach(({ tag, color }) => {
|
||||
if (typeof color !== 'string') {
|
||||
return;
|
||||
}
|
||||
(Array.isArray(tag) ? tag : [tag]).forEach((themeTag: Tag) => {
|
||||
if (!colorByTag.has(themeTag)) {
|
||||
colorByTag.set(themeTag, color);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
style: (tags): string | null => {
|
||||
const match = tags
|
||||
.flatMap((tag) => tag.set)
|
||||
.find((candidate) => colorByTag.has(candidate));
|
||||
return match ? (colorByTag.get(match) ?? null) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DARK_HIGHLIGHTER = createThemeHighlighter(copilotDarkStyle);
|
||||
const LIGHT_HIGHLIGHTER = createThemeHighlighter(githubLightStyle);
|
||||
|
||||
export function highlightQueryExpression(
|
||||
expression: string,
|
||||
isDarkMode: boolean,
|
||||
): HighlightedQueryPart[] {
|
||||
const tree = javascriptLanguage.parser.parse(expression);
|
||||
const parts: HighlightedQueryPart[] = [];
|
||||
let position = 0;
|
||||
|
||||
highlightTree(
|
||||
tree,
|
||||
isDarkMode ? DARK_HIGHLIGHTER : LIGHT_HIGHLIGHTER,
|
||||
(from, to, color) => {
|
||||
if (from > position) {
|
||||
parts.push({ from: position, text: expression.slice(position, from) });
|
||||
}
|
||||
parts.push({ from, text: expression.slice(from, to), color });
|
||||
position = to;
|
||||
},
|
||||
);
|
||||
|
||||
if (position < expression.length) {
|
||||
parts.push({ from: position, text: expression.slice(position) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient } from 'react-query';
|
||||
import {
|
||||
invalidateGetSavedView,
|
||||
invalidateListSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export async function invalidateSavedViews(
|
||||
queryClient: QueryClient,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
id?: string,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
invalidateListSavedViews(queryClient),
|
||||
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
|
||||
// The saved views bar still lists through v1.
|
||||
queryClient.invalidateQueries([{ sourcepage: source }]),
|
||||
]);
|
||||
}
|
||||
@@ -719,18 +719,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToTracesFunnel, () =>
|
||||
onClickHandler(ROUTES.TRACES_FUNNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToTracesViews, () =>
|
||||
onClickHandler(ROUTES.TRACES_SAVE_VIEWS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsSummary, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsExplorer, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER_EXPLORER, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToMetricsViews, () =>
|
||||
onClickHandler(ROUTES.METRICS_EXPLORER_VIEWS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettings, () =>
|
||||
onClickHandler(ROUTES.SETTINGS, null),
|
||||
);
|
||||
@@ -755,9 +749,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToLogsPipelines, () =>
|
||||
onClickHandler(ROUTES.LOGS_PIPELINES, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToLogsViews, () =>
|
||||
onClickHandler(ROUTES.LOGS_SAVE_VIEWS, null),
|
||||
);
|
||||
return (): void => {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToHome);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToServices);
|
||||
@@ -770,7 +761,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToTracesFunnel);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsSummary);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsExplorer);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToMetricsViews);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
@@ -779,8 +769,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsPipelines);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToLogsViews);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToTracesViews);
|
||||
};
|
||||
}, [deregisterShortcut, onClickHandler, registerShortcut]);
|
||||
|
||||
|
||||
@@ -43,8 +43,16 @@ export const getFieldKeysQueryOptions = (
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export type FieldKeysQueryOverrides = Partial<
|
||||
Omit<FieldKeysQueryOptions, 'queryKey' | 'queryFn' | 'select'>
|
||||
>;
|
||||
|
||||
export const useFieldKeysSuggestion = (
|
||||
fieldKeysConfig: FieldKeysConfig,
|
||||
builderQueryType?: BuilderQueryType,
|
||||
options?: FieldKeysQueryOverrides,
|
||||
): UseQueryResult<TelemetryFieldKey[], ErrorType<RenderErrorResponseDTO>> =>
|
||||
useQuery(getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType));
|
||||
useQuery({
|
||||
...getFieldKeysQueryOptions(fieldKeysConfig, builderQueryType),
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -61,11 +61,11 @@ function AllErrors(): JSX.Element {
|
||||
className="all-errors-page"
|
||||
contentClassName="all-errors-right-section"
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={handleFilterVisibilityChange}
|
||||
quickFilterProps={{
|
||||
className: 'qf-exceptions',
|
||||
source: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: SignalType.EXCEPTIONS,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -227,12 +227,12 @@ function LogsExplorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="logs-module-page"
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={handleFilterVisibilityChange}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.logs }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-logs-explorer',
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLocation } from 'react-use';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import history from 'lib/history';
|
||||
|
||||
import { logSaveView, logsExplorer, logsPipelines } from './constants';
|
||||
@@ -9,8 +10,13 @@ import './LogsModulePage.styles.scss';
|
||||
|
||||
export default function LogsModulePage(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routes: TabRoutes[] = [logsExplorer, logsPipelines, logSaveView];
|
||||
const routes: TabRoutes[] = [
|
||||
logsExplorer,
|
||||
logsPipelines,
|
||||
...(isSavedViewEnabled ? [] : [logSaveView]),
|
||||
];
|
||||
|
||||
return (
|
||||
<RouteTab
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useLocation } from 'react-use';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import history from 'lib/history';
|
||||
|
||||
import { Explorer, Meter, Views } from './constants';
|
||||
@@ -10,8 +11,13 @@ import './MeterExplorer.styles.scss';
|
||||
|
||||
function MeterExplorerPage(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routes: TabRoutes[] = [Meter, Explorer, Views];
|
||||
const routes: TabRoutes[] = [
|
||||
Meter,
|
||||
Explorer,
|
||||
...(isSavedViewEnabled ? [] : [Views]),
|
||||
];
|
||||
|
||||
return (
|
||||
<RouteTab
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&.is-explorer-tab .ant-tabs-content-holder {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-use';
|
||||
import cx from 'classnames';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import history from 'lib/history';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -16,15 +19,16 @@ import './MetricsExplorerPage.styles.scss';
|
||||
function MetricsExplorerPage(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { isVolumeControlEnabled } = useVolumeControlFeatureGate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routes: TabRoutes[] = useMemo(
|
||||
() => [
|
||||
Summary,
|
||||
...(isVolumeControlEnabled ? [VolumeControl] : []),
|
||||
Explorer,
|
||||
Views,
|
||||
...(isSavedViewEnabled ? [] : [Views]),
|
||||
],
|
||||
[isVolumeControlEnabled],
|
||||
[isVolumeControlEnabled, isSavedViewEnabled],
|
||||
);
|
||||
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
@@ -43,7 +47,9 @@ function MetricsExplorerPage(): JSX.Element {
|
||||
|
||||
return (
|
||||
<RouteTab
|
||||
className="metrics-explorer-page"
|
||||
className={cx('metrics-explorer-page', {
|
||||
'is-explorer-tab': pathname === ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
})}
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
@@ -134,6 +135,7 @@ function TracesExplorer(): JSX.Element {
|
||||
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
@@ -285,14 +287,12 @@ function TracesExplorer(): JSX.Element {
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
showFilters={isOpen}
|
||||
onToggleFilters={(): void => setOpen(!isOpen)}
|
||||
savedViewProps={{ source: SavedviewtypesSourceDTO.traces }}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
signal: SignalType.TRACES,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
@@ -384,13 +384,15 @@ function TracesExplorer(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
{!isSavedViewEnabled && (
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -3,6 +3,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import history from 'lib/history';
|
||||
|
||||
import { tracesExplorer, tracesFunnel, tracesSaveView } from './constants';
|
||||
@@ -11,11 +12,12 @@ import './TracesModulePage.styles.scss';
|
||||
|
||||
function TracesModulePage(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routes: TabRoutes[] = [
|
||||
tracesExplorer,
|
||||
tracesFunnel(pathname),
|
||||
tracesSaveView,
|
||||
...(isSavedViewEnabled ? [] : [tracesSaveView]),
|
||||
].filter(Boolean) as TabRoutes[];
|
||||
|
||||
const handleTabChange = (activeRoute: string): void => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -32,8 +32,8 @@ jest.mock('../updater/usePreferenceUpdater', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseListSavedViews = useListSavedViews as jest.MockedFunction<
|
||||
typeof useListSavedViews
|
||||
const mockedUseGetSavedView = useGetSavedView as jest.MockedFunction<
|
||||
typeof useGetSavedView
|
||||
>;
|
||||
|
||||
function makeView(
|
||||
@@ -55,18 +55,30 @@ function makeView(
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// Stable objects, like react-query's cache; a fresh one per render loops the
|
||||
// sync effect.
|
||||
function mockViews(views: SavedviewtypesSavedViewDTO[]): void {
|
||||
mockedUseListSavedViews.mockReturnValue({
|
||||
data: { status: 'success', data: views },
|
||||
} as unknown as ReturnType<typeof useListSavedViews>);
|
||||
const responses = new Map(
|
||||
views.map((view) => [
|
||||
view.id,
|
||||
{ data: { status: 'success', data: view }, dataUpdatedAt: 0 },
|
||||
]),
|
||||
);
|
||||
const notLoaded = { data: undefined, dataUpdatedAt: 0 };
|
||||
mockedUseGetSavedView.mockImplementation(
|
||||
({ id }) =>
|
||||
(responses.get(id) ?? notLoaded) as unknown as ReturnType<
|
||||
typeof useGetSavedView
|
||||
>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('usePreferenceSync in saved view mode', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseListSavedViews.mockReset();
|
||||
mockedUseGetSavedView.mockReset();
|
||||
});
|
||||
|
||||
it('fetches the list for the data source only in saved view mode', () => {
|
||||
it('fetches the view only in saved view mode with a view id', () => {
|
||||
mockViews([]);
|
||||
|
||||
renderHook(() =>
|
||||
@@ -77,8 +89,8 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockedUseListSavedViews).toHaveBeenCalledWith(
|
||||
{ source: 'logs' },
|
||||
expect(mockedUseGetSavedView).toHaveBeenCalledWith(
|
||||
{ id: '' },
|
||||
{ query: { enabled: false } },
|
||||
);
|
||||
});
|
||||
@@ -184,7 +196,7 @@ describe('usePreferenceSync in saved view mode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults when the saved view id is not in the list', () => {
|
||||
it('uses defaults while the saved view is not loaded', () => {
|
||||
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
@@ -26,20 +24,30 @@ export function usePreferenceSync({
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const { data: viewsData } = useListSavedViews(
|
||||
{ source: toSavedViewSource(dataSource) },
|
||||
{ query: { enabled: mode === PreferenceMode.SAVED_VIEW } },
|
||||
const isSavedViewMode = mode === PreferenceMode.SAVED_VIEW && !!savedViewId;
|
||||
const { data: viewData } = useGetSavedView(
|
||||
{ id: savedViewId ?? '' },
|
||||
{ query: { enabled: isSavedViewMode } },
|
||||
);
|
||||
|
||||
const [savedViewPreferences, setSavedViewPreferences] =
|
||||
useState<Preferences | null>(null);
|
||||
|
||||
// Seeded once per view: a refetch of the same view keeps columns edited in
|
||||
// memory. Discard writes the saved ones back through the updater.
|
||||
const seededViewId = useRef<string>();
|
||||
useEffect(() => {
|
||||
const spec = savedViewId
|
||||
? findSavedView(viewsData?.data, savedViewId)?.spec
|
||||
: undefined;
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(spec, dataSource));
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
const view = viewData?.data;
|
||||
if (view && view.id === savedViewId) {
|
||||
if (seededViewId.current !== savedViewId) {
|
||||
seededViewId.current = savedViewId;
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(view.spec, dataSource));
|
||||
}
|
||||
return;
|
||||
}
|
||||
seededViewId.current = undefined;
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(undefined, dataSource));
|
||||
}, [viewData, dataSource, savedViewId]);
|
||||
|
||||
// We are using a reSync state because we have URL updates as well as local storage updates
|
||||
// and we want to make sure we are always using the latest preferences
|
||||
|
||||
@@ -1,10 +1,61 @@
|
||||
import type { GetFieldsKeys200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest } from 'msw';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource, SIGNAL_TO_DATA_SOURCE } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
autocompleteKeysResponse,
|
||||
autocompleteValuesResponse,
|
||||
} from './__story_mockdata__/queryBuilderAutocomplete';
|
||||
|
||||
/**
|
||||
* The v5 keys list, served from the same per-signal fixture as the v3 pair so
|
||||
* the group-by and order-by filters open on the product's keys. A context
|
||||
* prefix in `searchText` narrows to that context the way the backend does.
|
||||
*/
|
||||
const fieldKeysResponseForSignal = (
|
||||
signal: string | null,
|
||||
searchText: string,
|
||||
): GetFieldsKeys200 => {
|
||||
const context = Object.values(TelemetrytypesFieldContextDTO).find(
|
||||
(ctx) => ctx && searchText.toLowerCase().startsWith(`${ctx}.`),
|
||||
);
|
||||
const keys = (
|
||||
autocompleteKeysResponse(
|
||||
SIGNAL_TO_DATA_SOURCE[signal as TelemetrytypesSignalDTO] ?? DataSource.LOGS,
|
||||
context ? searchText.slice(context.length + 1) : searchText,
|
||||
).data.attributeKeys ?? []
|
||||
).map((attribute) => ({
|
||||
name: attribute.key,
|
||||
signal: signal as TelemetrytypesSignalDTO,
|
||||
fieldContext:
|
||||
attribute.type === 'resource'
|
||||
? TelemetrytypesFieldContextDTO.resource
|
||||
: TelemetrytypesFieldContextDTO.attribute,
|
||||
fieldDataType: (attribute.dataType === DataTypes.Int64 ||
|
||||
attribute.dataType === DataTypes.Float64
|
||||
? TelemetrytypesFieldDataTypeDTO.number
|
||||
: attribute.dataType) as TelemetrytypesFieldDataTypeDTO,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
keys
|
||||
.filter((key) => !context || key.fieldContext === context)
|
||||
.map((key) => [key.name, [key]]),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The query shapes `src/mocks-server/handlers.ts` answers from its own fixture:
|
||||
* the APM resource attribute filter, the one case its two autocomplete handlers
|
||||
@@ -37,6 +88,17 @@ const isSharedFixtureValuesQuery = (params: URLSearchParams): boolean => {
|
||||
* both. Returning nothing hands the request on to the next matching handler.
|
||||
*/
|
||||
export const queryBuilderHandlers = [
|
||||
rest.get('http://localhost/api/v1/fields/keys', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
fieldKeysResponseForSignal(
|
||||
req.url.searchParams.get('signal'),
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
(req, res, ctx) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { Format } from 'constants/formats/types';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
BuilderQuerySource,
|
||||
DataSource,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
@@ -104,7 +105,7 @@ export type IBuilderQuery = {
|
||||
pageSize?: number;
|
||||
offset?: number;
|
||||
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
|
||||
source?: 'meter' | '';
|
||||
source?: BuilderQuerySource;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
/** Heatmap only. */
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Format } from 'constants/formats/types';
|
||||
@@ -32,6 +35,31 @@ export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
// Total, not Partial, so a signal added to the generated enum has to be mapped here
|
||||
// before it compiles.
|
||||
export const SIGNAL_TO_DATA_SOURCE: Record<
|
||||
TelemetrytypesSignalDTO,
|
||||
DataSource | undefined
|
||||
> = {
|
||||
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
|
||||
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
|
||||
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
|
||||
// The "unset" member: not a data source a query can be built against.
|
||||
[TelemetrytypesSignalDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export type BuilderQuerySource = `${TelemetrytypesSourceDTO}`;
|
||||
|
||||
// `source` tells meter and metrics apart, since both share the metrics signal:
|
||||
// meter sends 'meter', the rest is ''.
|
||||
export const QUERY_SOURCE_TO_FIELD_SOURCE: Record<
|
||||
BuilderQuerySource,
|
||||
TelemetrytypesSourceDTO | undefined
|
||||
> = {
|
||||
meter: TelemetrytypesSourceDTO.meter,
|
||||
'': undefined,
|
||||
};
|
||||
|
||||
export enum StringOperators {
|
||||
NOOP = 'noop',
|
||||
COUNT = 'count',
|
||||
|
||||
Reference in New Issue
Block a user