Compare commits

...

8 Commits

Author SHA1 Message Date
nikhilmantri0902
9ce53c704e refactor: share name helpers in pkg/types, create cloned rule from PostableRule 2026-10-14 22:45:14 +05:30
Nikhil Mantri
17c4fbf2d4 Merge branch 'main' into alerts/clone_alert_rule_api 2026-10-14 22:00:29 +05:30
Aditya Singh
aaec7c89c5 feat(saved-views): hide the old saved views ui behind the flag (#13028)
#### Description
- with the saved views flag on, the old views ui is hidden.. bottom
explorer bar on logs / traces / metrics / meter, the Views tabs on those
pages and the All Views link on the home widget. views are in the
explorer header and panel now.
- views shortcuts and the go to views palette entries removed, not
behind the flag since the flag turns on next.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Additional Information
- meter never had create alert / add to dashboard in its bar (hidden for
meter), so hiding it there loses nothing.. the bottom bar commit note
saying otherwise is wrong.
- old views urls still open the page with an empty pane when the flag is
on.. routes go away in the cleanup PR.
- flag off is same as main apart from the shortcuts.
2026-10-14 13:21:17 +00:00
Aditya Singh
57c193ec8f feat(saved-views): all views panel in the quick filters sidebar (#13022)
#### Description
- real all views panel in the sidebar.. views split into created by me /
created by others, search by name, `↺` on the open view to clear it.
empty state has a create view button, same modal as the header.
- row `...` menu with copy link, edit view details and delete. copy link
copies the whole explorer link, a link with only the viewKey does not
open the view (same in the v1 bar).
- hover card on a view like the trace waterfall one.. filter per query
with items and expression combined, coloured with the query search
theme, metric line for metrics and meter, last updated.
`@lezer/highlight` added for the colouring, same version already in the
tree.
- explorer columns for a view now come from the view itself, not the
views list.. if the list call failed the explorer fell back to default
columns and the header went amber with no way to discard. header shows a
red failed to load state when the view itself does not load.
- unsaved changes check maps the stored view through the same mapper as
the page.. older views kept fields we never write (`reduceTo` on a
graph) and were always amber.
- qf settings drawer matched to the panel, sidebar tooltips wait 500ms,
meter explorer gets saved views, metrics explorer tab loses the extra
padding around the sidebar.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Screen recording



https://github.com/user-attachments/assets/f882b5b7-c108-49f3-b62e-132b0db3a60f



#### Additional Information
- behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- make default, duplicate and add to shortcuts not in this PR.. default
needs a backend api.
- qf settings drawer is a shared component, every qf page gets the new
header and search.
- browser pass on logs / traces / metrics / meter in progress.
2026-10-14 13:20:20 +00:00
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
93 changed files with 3808 additions and 714 deletions

View File

@@ -24919,6 +24919,75 @@ paths:
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/clone:
post:
deprecated: false
description: This endpoint creates a new alert rule from the stored definition
of an existing one. The clone keeps every modeled field of the source, including
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
clone is owned by the caller.
operationId: CloneRuleByID
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone alert rule
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/history/filter_keys:
get:
deprecated: false

View File

@@ -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",

View File

@@ -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)

View File

@@ -18,6 +18,8 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys

View File

@@ -14580,6 +14580,17 @@ export type PatchRuleByID200 = {
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByID201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};

View File

@@ -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"

View File

@@ -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

View File

@@ -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();
});
});
});

View File

@@ -0,0 +1 @@
export const SIDEBAR_TOOLTIP_DELAY_MS = 500;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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;

View File

@@ -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
{

View File

@@ -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',

View File

@@ -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,
}}
>

View File

@@ -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}>

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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}

View 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;

View File

@@ -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> => {

View File

@@ -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;

View 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);
}

View 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;

View 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;

View File

@@ -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;
}

View File

@@ -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>

View 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);
}

View 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;

View 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;

View 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;

View File

@@ -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);
}

View File

@@ -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>
);
}

View 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;

View 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;

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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));

View File

@@ -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]);

View File

@@ -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');
});
});

View File

@@ -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'));
});
});

View 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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,

View 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],
);
}

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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 };
}

View 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();
},
};
}

View File

@@ -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>;
}

View File

@@ -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({

View File

@@ -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([]);
});
});

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 }]),
]);
}

View File

@@ -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]);

View File

@@ -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,
}}
>

View File

@@ -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,
}}
>

View File

@@ -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

View File

@@ -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

View File

@@ -12,6 +12,10 @@
padding: 16px;
}
&.is-explorer-tab .ant-tabs-content-holder {
padding: 0;
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -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}

View File

@@ -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>

View File

@@ -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 => {

View File

@@ -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(() =>

View File

@@ -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

View File

@@ -71,6 +71,20 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},

View File

@@ -546,6 +546,24 @@ func (m *Manager) deleteTask(taskName string) {
// CreateRule stores rule def into db and also
// starts an executor for the rule, unless the rule is disabled
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
parsedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
return nil, err
}
return m.createRule(ctx, parsedRule, ruleStr)
}
func (m *Manager) CreatePostableRule(ctx context.Context, rule *ruletypes.PostableRule) (*ruletypes.GettableRule, error) {
data, err := json.Marshal(rule)
if err != nil {
return nil, err
}
return m.createRule(ctx, *rule, string(data))
}
func (m *Manager) createRule(ctx context.Context, parsedRule ruletypes.PostableRule, data string) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
@@ -555,11 +573,6 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
if err != nil {
return nil, err
}
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
if err != nil {
return nil, err
}
if err := parsedRule.Validate(); err != nil {
return nil, err
}
@@ -579,7 +592,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
Data: ruleStr,
Data: data,
OrgID: claims.OrgID,
}

View File

@@ -7,6 +7,7 @@ type Handler interface {
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)
CloneRuleByID(http.ResponseWriter, *http.Request)
UpdateRuleByID(http.ResponseWriter, *http.Request)
DeleteRuleByID(http.ResponseWriter, *http.Request)
PatchRuleByID(http.ResponseWriter, *http.Request)

View File

@@ -29,6 +29,9 @@ type Ruler interface {
// model to store structured data.
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
// CloneRule creates a new rule from the stored definition of id, with a " - Copy" suffixed name.
CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
// EditRule replaces the rule identified by id with the given JSON string.
// TODO: same as CreateRule — accept PostableRule instead of raw string.
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error

View File

@@ -105,6 +105,25 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
rule, err := handler.ruler.CloneRule(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -128,6 +129,30 @@ func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*rule
return provider.manager.CreateRule(ctx, ruleStr)
}
func (provider *provider) CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
storedRule, err := provider.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return nil, err
}
postableRule, err := storedRule.ToPostableRuleForCloning()
if err != nil {
return nil, err
}
return provider.manager.CreatePostableRule(ctx, postableRule)
}
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
return provider.manager.EditRule(ctx, ruleStr, id)
}

View File

@@ -74,7 +74,7 @@ type savedViewData struct {
const migrationSavedViewNameSuffixLen = 8
// slugifySavedViewName turns a pre-existing free-text saved view name and is copy of
// dashboardtypes.generateDashboardName.
// types.GenerateName.
func slugifySavedViewName(displayName string) string {
const dns1123LabelMaxLen = 63
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")

View File

@@ -131,7 +131,7 @@ func (migration *addChannelDisplayName) Down(context.Context, *bun.DB) error {
const migrationChannelNameSuffixLen = 8
// slugifyChannelName is a copy of dashboardtypes.generateDashboardName. The
// slugifyChannelName is a copy of types.GenerateName. The
// random suffix is what makes the unique index safe to add without a collision
// loop over the existing free-text names.
func slugifyChannelName(displayName string) string {

View File

@@ -2,10 +2,8 @@ package alertmanagertypes
import (
"context"
"crypto/rand"
"encoding/json"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
@@ -84,47 +82,6 @@ func (c *Channel) fillSpec(spec ChannelSpec) error {
return nil
}
const channelNameSuffixLen = 8
// generateChannelName is a copy of dashboardtypes.generateDashboardName: slugify
// the display name, then append a random suffix rather than looping on collisions.
func generateChannelName(displayName string) string {
const dns1123LabelMaxLen = 63
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
var b strings.Builder
b.Grow(len(displayName))
prevHyphen := false
for _, r := range strings.ToLower(displayName) {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevHyphen = false
case b.Len() > 0 && !prevHyphen:
b.WriteByte('-')
prevHyphen = true
}
}
prefix := strings.TrimRight(b.String(), "-")
suffix := make([]byte, channelNameSuffixLen)
if _, err := rand.Read(suffix); err != nil {
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for channel name suffix"))
}
for i := range suffix {
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
}
maxPrefix := dns1123LabelMaxLen - 1 - channelNameSuffixLen
if len(prefix) > maxPrefix {
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
}
if prefix == "" {
return string(suffix)
}
return prefix + "-" + string(suffix)
}
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
cfg, err := NewDefaultConfig(
globalConfig,

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"k8s.io/apimachinery/pkg/util/validation"
)
@@ -42,7 +43,7 @@ func (p *PostableNotificationChannel) UnmarshalJSON(data []byte) error {
}
if p.GenerateName {
p.Name = generateChannelName(p.DisplayName)
p.Name = types.GenerateName(p.DisplayName)
}
return nil

View File

@@ -128,7 +128,7 @@ func newChannelWithoutSpec(receiver *Receiver, orgID string) (*Channel, error) {
return &Channel{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
Name: generateChannelName(receiver.Name),
Name: types.GenerateName(receiver.Name),
DisplayName: receiver.Name,
Type: channelType,
Data: string(data),

View File

@@ -2,14 +2,10 @@ package dashboardtypes
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
@@ -20,9 +16,8 @@ import (
)
const (
SchemaVersion = "v6"
MaxTagsPerDashboard = 10
dashboardNameSuffixLen = 8
SchemaVersion = "v6"
MaxTagsPerDashboard = 10
)
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
@@ -161,7 +156,7 @@ func (d *DashboardV2) ErrIfNotClonable() error {
func (d DashboardV2) ToPostableForCloning() PostableDashboardV2 {
spec := d.Spec
spec.Display.Name = nextCloneDisplayName(spec.Display.Name)
spec.Display.Name = types.GenerateCloneName(spec.Display.Name, MaxDisplayNameLen)
return PostableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
GenerateName: true,
@@ -170,33 +165,6 @@ func (d DashboardV2) ToPostableForCloning() PostableDashboardV2 {
}
}
// cloneCopySuffixRegex matches a " - Copy" or " - Copy (n)" suffix on a display name.
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
// nextCloneDisplayName appends " - Copy" to a clone's display name, bumping an
// existing " - Copy (n)" counter, then truncates the base to fit MaxDisplayNameLen.
func nextCloneDisplayName(name string) string {
base, count := name, 0
if m := cloneCopySuffixRegex.FindStringSubmatch(name); m != nil {
base = m[1]
count = 1 // bare " - Copy"
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
suffix := " - Copy"
if count++; count > 1 {
suffix = fmt.Sprintf(" - Copy (%d)", count)
}
limit := max(MaxDisplayNameLen-utf8.RuneCountInString(suffix), 0)
if runes := []rune(base); len(runes) > limit {
base = strings.TrimRight(string(runes[:limit]), " ")
}
return base + suffix
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image"`
@@ -233,7 +201,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
name := postable.Name
if postable.GenerateName {
name = generateDashboardName(postable.Spec.Display.Name)
name = types.GenerateName(postable.Spec.Display.Name)
}
// Checked on the final name, here rather than in validateName, because only
// the constructor knows the source.
@@ -313,43 +281,6 @@ func validateDashboardName(name string) error {
return nil
}
func generateDashboardName(displayName string) string {
const dns1123LabelMaxLen = 63
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
var b strings.Builder
b.Grow(len(displayName))
prevHyphen := false
for _, r := range strings.ToLower(displayName) {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevHyphen = false
case b.Len() > 0 && !prevHyphen:
b.WriteByte('-')
prevHyphen = true
}
}
prefix := strings.TrimRight(b.String(), "-")
suffix := make([]byte, dashboardNameSuffixLen)
if _, err := rand.Read(suffix); err != nil {
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for dashboard name suffix"))
}
for i := range suffix {
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
}
maxPrefix := dns1123LabelMaxLen - 1 - dashboardNameSuffixLen
if len(prefix) > maxPrefix {
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
}
if prefix == "" {
return string(suffix)
}
return prefix + "-" + string(suffix)
}
func validateDashboardTags(tags []tagtypes.PostableTag) error {
if len(tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)

View File

@@ -5,7 +5,6 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -184,7 +183,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
assert.Len(t, dashboard.Name, len("my-dashboard-")+types.GeneratedNameSuffixLen)
})
}
@@ -239,83 +238,6 @@ func TestDashboardV2ToPostableForCloning(t *testing.T) {
}
}
// nextCloneDisplayName appends " - Copy", bumps an existing " - Copy (n)"
// counter, and truncates an over-long base back to MaxDisplayNameLen while
// keeping the suffix whole. The long cases are real-ish titles already at the
// limit so the truncated output is legible; their literal expectations assume
// the 128-character limit.
func TestNextCloneDisplayName(t *testing.T) {
require.Equal(t, 128, MaxDisplayNameLen, "the literal expectations below are sized for a 128-character limit")
testCases := []struct {
scenario string
input string
expected string
}{
{
scenario: "plain name gets a Copy suffix",
input: "My Dashboard",
expected: "My Dashboard - Copy",
},
{
scenario: "Copy suffix bumps to (2)",
input: "My Dashboard - Copy",
expected: "My Dashboard - Copy (2)",
},
{
scenario: "numbered suffix increments",
input: "My Dashboard - Copy (2)",
expected: "My Dashboard - Copy (3)",
},
{
scenario: "multi-digit suffix increments",
input: "svc - Copy (41)",
expected: "svc - Copy (42)",
},
{
scenario: "a name that merely contains Copy is not a suffix",
input: "Copy of things",
expected: "Copy of things - Copy",
},
{
scenario: "only the trailing Copy marker is stripped",
input: "Prod - Copy - Copy",
expected: "Prod - Copy - Copy (2)",
},
{
scenario: "empty name",
input: "",
expected: " - Copy",
},
{
scenario: "first copy at the limit truncates the base, keeps the suffix",
input: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availability Zone",
expected: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availabili - Copy",
},
{
scenario: "numbered copy at the limit increments then truncates",
input: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Service - Copy (9)",
expected: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Servic - Copy (10)",
},
{
scenario: "truncation counts runes, not bytes (é and — are one rune each)",
input: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Fleet",
expected: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices F - Copy",
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
require.LessOrEqual(t, utf8.RuneCountInString(testCase.input), MaxDisplayNameLen, "a saved source name never exceeds the limit")
result := nextCloneDisplayName(testCase.input)
assert.Equal(t, testCase.expected, result)
assert.LessOrEqual(t, utf8.RuneCountInString(result), MaxDisplayNameLen, "a clone name must fit the limit")
})
}
}
func TestNewLegacyListedDashboardV2(t *testing.T) {
orgID := valuer.GenerateUUID()
dashboardID := valuer.GenerateUUID()

View File

@@ -14,7 +14,6 @@ import (
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
)
func unmarshalDashboard(data []byte) (*DashboardSpec, error) {
@@ -2117,53 +2116,6 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
}
}
func TestGenerateDashboardName(t *testing.T) {
tests := []struct {
scenario string
input string
wantPrefix string // expected slug prefix before the "-<suffix>" tail (empty if prefix is dropped)
}{
{scenario: "simple words with spaces", input: "My Dashboard", wantPrefix: "my-dashboard"},
{scenario: "punctuation collapses", input: "Hello, World!", wantPrefix: "hello-world"},
{scenario: "leading and trailing whitespace", input: " hello ", wantPrefix: "hello"},
{scenario: "leading and trailing hyphens", input: "---abc---", wantPrefix: "abc"},
{scenario: "consecutive non-alphanumerics collapse", input: "a___b...c", wantPrefix: "a-b-c"},
{scenario: "digits are preserved", input: "Region us-east-1", wantPrefix: "region-us-east-1"},
{scenario: "no alphanumerics drops prefix and returns suffix only", input: "!!! ???", wantPrefix: ""},
}
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
got := generateDashboardName(tt.input)
assert.NotEmpty(t, got)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
if tt.wantPrefix == "" {
assert.Len(t, got, dashboardNameSuffixLen, "expected the bare random suffix")
return
}
expectedPrefix := tt.wantPrefix + "-"
assert.True(t, strings.HasPrefix(got, expectedPrefix), "expected prefix %q, got %q", expectedPrefix, got)
assert.Len(t, got, len(expectedPrefix)+dashboardNameSuffixLen)
})
}
t.Run("prefix is truncated to leave room for the suffix", func(t *testing.T) {
input := strings.Repeat("a", 100)
got := generateDashboardName(input)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got))
assert.Equal(t, len(got), 63, "expected the result to be padded to the max DNS-1123 length")
})
t.Run("suffix differs across calls", func(t *testing.T) {
first := generateDashboardName("collision-test")
second := generateDashboardName("collision-test")
assert.NotEqual(t, first, second, "expected the random suffix to differ across calls")
})
}
func TestSpanGaps(t *testing.T) {
unmarshal := func(t *testing.T, val string) SpanGaps {
t.Helper()

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
)
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
@@ -95,7 +96,7 @@ func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err erro
SchemaVersion: SchemaVersion,
Image: image,
},
Name: generateDashboardName(title),
Name: types.GenerateName(title),
Tags: tags,
Spec: spec,
}, nil
@@ -104,5 +105,5 @@ func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err erro
// V1Name derives the v2 name column from a v1 dashboard's title. Used to backfill the
// name of a dashboard whose data fails to migrate, so it stays findable by name.
func (storable StorableDashboard) V1Name() string {
return generateDashboardName(storable.Data.readString("title"))
return types.GenerateName(storable.Data.readString("title"))
}

79
pkg/types/name.go Normal file
View File

@@ -0,0 +1,79 @@
package types
import (
"crypto/rand"
"fmt"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
)
const GeneratedNameSuffixLen = 8
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
// GenerateName slugifies displayName into a DNS-1123 label and appends a random suffix; uniqueness is left to the caller's unique index.
func GenerateName(displayName string) string {
const dns1123LabelMaxLen = 63
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
var b strings.Builder
b.Grow(len(displayName))
prevHyphen := false
for _, r := range strings.ToLower(displayName) {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevHyphen = false
case b.Len() > 0 && !prevHyphen:
b.WriteByte('-')
prevHyphen = true
}
}
prefix := strings.TrimRight(b.String(), "-")
suffix := make([]byte, GeneratedNameSuffixLen)
if _, err := rand.Read(suffix); err != nil {
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for generated name suffix"))
}
for i := range suffix {
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
}
maxPrefix := dns1123LabelMaxLen - 1 - GeneratedNameSuffixLen
if len(prefix) > maxPrefix {
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
}
if prefix == "" {
return string(suffix)
}
return prefix + "-" + string(suffix)
}
// GenerateCloneName appends " - Copy" or bumps an existing " - Copy (n)" counter, truncating the base in runes to fit maxLen; maxLen 0 means no limit.
func GenerateCloneName(name string, maxLen int) string {
base, count := name, 0
if m := cloneCopySuffixRegex.FindStringSubmatch(name); m != nil {
base = m[1]
count = 1 // bare " - Copy"
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
suffix := " - Copy"
if count++; count > 1 {
suffix = fmt.Sprintf(" - Copy (%d)", count)
}
if maxLen > 0 {
limit := max(maxLen-utf8.RuneCountInString(suffix), 0)
if runes := []rune(base); len(runes) > limit {
base = strings.TrimRight(string(runes[:limit]), " ")
}
}
return base + suffix
}

97
pkg/types/name_test.go Normal file
View File

@@ -0,0 +1,97 @@
package types
import (
"strings"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/assert"
"k8s.io/apimachinery/pkg/util/validation"
)
func TestGenerateName(t *testing.T) {
testCases := []struct {
name string
input string
wantPrefix string
}{
{name: "WordsWithSpaces_Hyphenated", input: "My Dashboard", wantPrefix: "my-dashboard"},
{name: "Punctuation_Collapsed", input: "Hello, World!", wantPrefix: "hello-world"},
{name: "SurroundingWhitespace_Trimmed", input: " hello ", wantPrefix: "hello"},
{name: "SurroundingHyphens_Trimmed", input: "---abc---", wantPrefix: "abc"},
{name: "ConsecutiveNonAlphanumerics_Collapsed", input: "a___b...c", wantPrefix: "a-b-c"},
{name: "Digits_Preserved", input: "Region us-east-1", wantPrefix: "region-us-east-1"},
{name: "NoAlphanumerics_SuffixOnly", input: "!!! ???", wantPrefix: ""},
{name: "OverLongPrefix_TruncatedToLabelLimit", input: strings.Repeat("a", 100), wantPrefix: strings.Repeat("a", 63-1-GeneratedNameSuffixLen)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := GenerateName(testCase.input)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
if testCase.wantPrefix == "" {
assert.Len(t, got, GeneratedNameSuffixLen)
return
}
assert.True(t, strings.HasPrefix(got, testCase.wantPrefix+"-"), "expected prefix %q, got %q", testCase.wantPrefix+"-", got)
assert.Len(t, got, len(testCase.wantPrefix)+1+GeneratedNameSuffixLen)
})
}
}
func TestGenerateName_SameInput_SuffixDiffers(t *testing.T) {
assert.NotEqual(t, GenerateName("collision-test"), GenerateName("collision-test"))
}
func TestGenerateCloneName(t *testing.T) {
testCases := []struct {
name string
input string
maxLen int
expected string
}{
{name: "PlainName_CopySuffixAppended", input: "My Dashboard", expected: "My Dashboard - Copy"},
{name: "CopySuffix_BumpedToTwo", input: "My Dashboard - Copy", expected: "My Dashboard - Copy (2)"},
{name: "NumberedSuffix_Incremented", input: "My Dashboard - Copy (2)", expected: "My Dashboard - Copy (3)"},
{name: "MultiDigitSuffix_Incremented", input: "svc - Copy (41)", expected: "svc - Copy (42)"},
{name: "ContainsCopyWord_NotTreatedAsSuffix", input: "Copy of things", expected: "Copy of things - Copy"},
{name: "RepeatedCopy_OnlyTrailingMarkerParsed", input: "Prod - Copy - Copy", expected: "Prod - Copy - Copy (2)"},
{name: "EmptyName_SuffixOnly", input: "", expected: " - Copy"},
{
name: "NoLimit_LongNameNotTruncated",
input: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters",
expected: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters - Copy",
},
{
name: "AtLimit_FirstCopy_BaseTruncatedSuffixKept",
input: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availability Zone",
maxLen: 128,
expected: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availabili - Copy",
},
{
name: "AtLimit_NumberedCopy_IncrementedThenTruncated",
input: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Service - Copy (9)",
maxLen: 128,
expected: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Servic - Copy (10)",
},
{
name: "AtLimit_MultiByteRunes_TruncatedByRunes",
input: "Café Latency: p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Für All",
maxLen: 128,
expected: "Café Latency: p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Fü - Copy",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
got := GenerateCloneName(testCase.input, testCase.maxLen)
assert.Equal(t, testCase.expected, got)
if testCase.maxLen > 0 {
assert.LessOrEqual(t, utf8.RuneCountInString(got), testCase.maxLen)
}
})
}
}

View File

@@ -48,6 +48,15 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
return stats
}
func (rule *StorableRule) ToPostableRuleForCloning() (*PostableRule, error) {
postable := &PostableRule{}
if err := json.Unmarshal([]byte(rule.Data), postable); err != nil {
return nil, err
}
postable.AlertName = types.GenerateCloneName(postable.AlertName, 0)
return postable, nil
}
// RuleAlert represents an alert associated with a rule, used when filtering by metric name.
type RuleAlert struct {
AlertName string

View File

@@ -0,0 +1,93 @@
package ruletypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStorableRule_ToPostableRuleForCloning(t *testing.T) {
v1Stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"description": "watches cpu",
"ruleType": "threshold_rule",
"version": "v5",
"evalWindow": "10m",
"frequency": "2m",
"disabled": true,
"source": "https://signoz.example.com/alerts/new",
"labels": {"team": "infra", "severity": "warning"},
"annotations": {"summary": "cpu above {{$threshold}}"},
"preferredChannels": ["slack-infra"],
"condition": {
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu_usage", "timeAggregation": "avg", "spaceAggregation": "max"}]}}]
},
"selectedQueryName": "A",
"op": "1",
"target": 90,
"matchType": "1",
"alertOnAbsent": true,
"absentFor": 5
}
}`
v2Stored := `{
"alert": "cpu high - Copy",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "promql_rule",
"schemaVersion": "v2alpha1",
"version": "v5",
"disabled": false,
"labels": {"team": "infra"},
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "avg(cpu_usage)"}}],
"panelType": "graph",
"queryType": "promql"
},
"selectedQueryName": "A",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "90m", "frequency": "90s"}},
"notificationSettings": {"groupBy": ["service.name"], "renotify": {"enabled": true, "interval": "45m", "alertStates": ["firing"]}, "usePolicy": true}
}`
testCases := []struct {
name string
stored string
expectedName string
expectErr bool
}{
{name: "V1Rule_Cloned_NameSuffixedRestIntact", stored: v1Stored, expectedName: "cpu high - Copy"},
{name: "V2Alpha1Rule_Cloned_NameBumpedRestIntact", stored: v2Stored, expectedName: "cpu high - Copy (2)"},
{name: "InvalidJSON_Error", stored: `{"alert": `, expectErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &StorableRule{Data: testCase.stored}
cloned, err := rule.ToPostableRuleForCloning()
if testCase.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
expected := &PostableRule{}
require.NoError(t, json.Unmarshal([]byte(testCase.stored), expected))
expected.AlertName = testCase.expectedName
marshaled, err := json.Marshal(cloned)
require.NoError(t, err)
roundTripped := &PostableRule{}
require.NoError(t, json.Unmarshal(marshaled, roundTripped))
assert.Equal(t, expected, roundTripped)
})
}
}

View File

@@ -1,7 +1,6 @@
package savedviewtypes
import (
"crypto/rand"
"strings"
"time"
@@ -19,9 +18,6 @@ var (
ErrCodeSavedViewNotFound = errors.MustNewCode("saved_view_not_found")
)
// savedViewNameSuffixLen mirrors dashboardtypes' generated-name logic.
const savedViewNameSuffixLen = 8
var (
SourceTraces = Source{valuer.NewString("traces")}
SourceLogs = Source{valuer.NewString("logs")}
@@ -136,7 +132,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
name := postable.Name
if postable.GenerateName {
name = generateSavedViewName(postable.Spec.DisplayName)
name = types.GenerateName(postable.Spec.DisplayName)
}
return &SavedView{
@@ -243,43 +239,3 @@ func validateSavedViewName(name string) error {
}
return nil
}
// generateSavedViewName is a copy of dashboardtypes.generateDashboardName: slugify
// the display name and append a random suffix for practical collision avoidance
// (the DB unique index on (org_id, name) is what actually guarantees uniqueness).
func generateSavedViewName(displayName string) string {
const dns1123LabelMaxLen = 63
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
var b strings.Builder
b.Grow(len(displayName))
prevHyphen := false
for _, r := range strings.ToLower(displayName) {
switch {
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevHyphen = false
case b.Len() > 0 && !prevHyphen:
b.WriteByte('-')
prevHyphen = true
}
}
prefix := strings.TrimRight(b.String(), "-")
suffix := make([]byte, savedViewNameSuffixLen)
if _, err := rand.Read(suffix); err != nil {
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for saved view name suffix"))
}
for i := range suffix {
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
}
maxPrefix := dns1123LabelMaxLen - 1 - savedViewNameSuffixLen
if len(prefix) > maxPrefix {
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
}
if prefix == "" {
return string(suffix)
}
return prefix + "-" + string(suffix)
}

View File

@@ -186,45 +186,6 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
assert.Equal(t, "My View!", savedView.Spec.DisplayName)
}
func TestGenerateSavedViewName(t *testing.T) {
tests := []struct {
scenario string
input string
wantPrefix string
}{
{scenario: "simple words with spaces", input: "My View", wantPrefix: "my-view"},
{scenario: "punctuation collapses", input: "Hello, World!", wantPrefix: "hello-world"},
{scenario: "leading and trailing whitespace", input: " hello ", wantPrefix: "hello"},
{scenario: "leading and trailing hyphens", input: "---abc---", wantPrefix: "abc"},
{scenario: "consecutive non-alphanumerics collapse", input: "a___b...c", wantPrefix: "a-b-c"},
{scenario: "digits are preserved", input: "Region us-east-1", wantPrefix: "region-us-east-1"},
{scenario: "no alphanumerics drops prefix and returns suffix only", input: "!!! ???", wantPrefix: ""},
}
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
got := generateSavedViewName(tt.input)
assert.NotEmpty(t, got)
assert.LessOrEqual(t, len(got), 63)
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
if tt.wantPrefix == "" {
assert.Len(t, got, savedViewNameSuffixLen, "expected the bare random suffix")
return
}
expectedPrefix := tt.wantPrefix + "-"
assert.True(t, strings.HasPrefix(got, expectedPrefix), "expected prefix %q, got %q", expectedPrefix, got)
assert.Len(t, got, len(expectedPrefix)+savedViewNameSuffixLen)
})
}
t.Run("suffix differs across calls", func(t *testing.T) {
first := generateSavedViewName("collision-test")
second := generateSavedViewName("collision-test")
assert.NotEqual(t, first, second, "expected the random suffix to differ across calls")
})
}
func TestStorableSavedView_ToSavedView(t *testing.T) {
t.Run("round trip preserves populated fields", func(t *testing.T) {
view := &SavedView{

View File

@@ -0,0 +1,161 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.alerts import delete_all_rules
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
CHANNEL_NAME = f"clone-rule-channel-{uuid.uuid4()}"
SOURCE_RULE = {
"alert": "clone me",
"description": "source rule for clone",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [CHANNEL_NAME]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "clone_rule_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical", "team": "payments"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
"notificationSettings": {
"groupBy": [],
"usePolicy": False,
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
},
"version": "v5",
"schemaVersion": "v2alpha1",
}
def test_clone_rule(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
delete_all_rules(signoz, token)
create_notification_channel({"name": CHANNEL_NAME, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]})
source_id = create_alert_rule(SOURCE_RULE)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
source = response.json()["data"]
# clone keeps everything but the id and name
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone = response.json()["data"]
assert clone["id"] != source_id
assert clone["alert"] == "clone me - Copy"
for field in ["description", "alertType", "ruleType", "disabled", "labels", "annotations", "condition", "evaluation", "notificationSettings"]:
assert clone[field] == source[field], field
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["alert"] == "clone me - Copy"
assert response.json()["data"]["state"] == "disabled"
assert response.json()["data"]["createdBy"] == USER_ADMIN_EMAIL
# cloning a copy bumps the counter
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
second_clone = response.json()["data"]
assert second_clone["alert"] == "clone me - Copy (2)"
assert second_clone["condition"] == source["condition"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
# a source whose channel was deleted since no longer passes create validation
stale_channel_name = f"clone-rule-stale-{uuid.uuid4()}"
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json={"name": stale_channel_name, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
stale_channel_id = response.json()["data"]["id"]
stale_rule = {
**SOURCE_RULE,
"alert": "stale channel",
"condition": {**SOURCE_RULE["condition"], "thresholds": {"kind": "basic", "spec": [{**SOURCE_RULE["condition"]["thresholds"]["spec"][0], "channels": [stale_channel_name]}]}},
"notificationSettings": {**SOURCE_RULE["notificationSettings"], "usePolicy": True},
}
stale_rule_id = create_alert_rule(stale_rule)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{stale_channel_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{stale_rule_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert stale_channel_name in response.json()["error"]["message"]
for rule_id in [clone["id"], second_clone["id"]]:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text