Compare commits

..

9 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
Tushar Vats
da556abac1 feat(logparsingpipeline): order user pipelines around signozlogsnormalizer (#13106)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Opamp places user log pipelines around the first
`signozlogsnormalizer` in the logs pipeline, per its `opamp.position`:
the normalizer runs after them by default (`after`), before them with
`before`. Other processors keep their places.
- Without the normalizer, processor ordering is unchanged.
- The `use_json_body`-prepended normalize operator is dropped when the
normalizer already runs ahead of user pipelines (`before`); otherwise
both are kept.
- Removes the `json_body_dual_ingestion` flag: the normalize operator no
longer stashes the original body, and isn't appended after user
pipelines when only that flag is on.

| `use_json_body` | `opamp.position` | Logs pipeline | Normalize
operator |
|---|---|---|---|
| off | no normalizer | memory_limiter → user → custom → batch
(unchanged) | — |
| off | `after` (default) | … → user → normalizer → … | — |
| off | `before` | … → normalizer → user → … | — |
| on | no normalizer | memory_limiter → normalize op → user → custom →
batch | prepended |
| on | `after` (default) | … → normalize op → user → normalizer → … |
prepended (both normalize) |
| on | `before` | … → normalizer → user → … | dropped |

#### Additional Information

- Depends on SigNoz/signoz-otel-collector#953 for the `opamp.position`
config.
- The pipelines preview still prepends the operator under
`use_json_body`, since it can't see per-collector `opamp.position`.
- Configs still setting `json_body_dual_ingestion` keep working; the
unknown flag is skipped with a warning.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 15:32:50 +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
196 changed files with 4662 additions and 6185 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

@@ -142,7 +142,7 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', ['Overview', 'Thread'], 'Funnel Details'],
['Explorer', 'Trace Details', 'Funnel Details'],
'Metrics',
['Explorer'],
'Infrastructure',

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

@@ -1,387 +0,0 @@
@use '../../../styles/scrollbar' as *;
// Design token Paragraph/Base/400.
@mixin paragraph-base {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.roleHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
color: var(--l1-foreground);
}
// Typography sets size, weight and line height; the mixin adds the rest.
.roleLabel {
@include paragraph-base;
}
.parts {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.empty {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.textBox {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-5);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
min-width: 0;
}
.text {
@include paragraph-base;
color: var(--l2-foreground);
word-break: break-word;
:global(p) {
margin: 0 0 var(--spacing-4);
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(:is(ol, ul)) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-10);
}
:global(li::marker) {
color: var(--l2-foreground);
}
:global(:is(h1, h2, h3, h4, h5, h6)) {
margin: var(--spacing-6) 0 var(--spacing-4);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-20);
color: var(--l1-foreground);
}
:global(:is(h1, h2)) {
font-size: var(--periscope-font-size-medium);
}
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
margin-top: 0;
}
:global(strong) {
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
:global(a) {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:global(code:not(pre code)) {
padding: 0 var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l3-background);
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--l1-foreground);
}
:global(pre) {
position: relative;
margin: 0 0 var(--spacing-4);
}
// The highlighter sets its box with inline styles; syntax colours stay its own.
:global(pre > div:not(.code-copy-btn)) {
margin: 0 !important;
padding: var(--spacing-4) var(--spacing-5) !important;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2) !important;
background: var(--l1-background) !important;
overflow-x: auto;
}
:global(pre code) {
font-family: var(--font-mono) !important;
}
:global(blockquote) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-5);
border-left: 2px solid var(--l3-border);
color: var(--l2-foreground);
}
:global(hr) {
margin: var(--spacing-6) 0;
border: none;
border-top: 1px solid var(--l2-border);
}
:global(table) {
margin: 0 0 var(--spacing-4);
border-collapse: collapse;
}
:global(:is(th, td)) {
padding: var(--spacing-2) var(--spacing-4);
border: 1px solid var(--l2-border);
text-align: left;
}
:global(th) {
background: var(--l2-background);
font-weight: var(--font-weight-semibold);
}
}
.plainText {
white-space: pre-wrap;
}
.showMore {
align-self: flex-start;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--primary);
cursor: pointer;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
max-width: 100%;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-2);
background: var(--l2-background);
color: var(--l2-foreground);
min-width: 0;
@include paragraph-base;
&.isPlain {
padding: 0;
background: none;
}
}
.dot {
flex-shrink: 0;
width: var(--spacing-3);
height: var(--spacing-3);
border-radius: var(--radius-round);
&[data-tone='success'] {
background: var(--accent-forest);
}
&[data-tone='warning'] {
background: var(--accent-amber);
}
&[data-tone='error'] {
background: var(--accent-cherry);
}
&[data-tone='info'] {
background: var(--accent-primary);
}
}
.chipLabel {
flex-shrink: 0;
}
.chipSeparator {
flex-shrink: 0;
color: var(--l2-foreground);
}
.chipValue {
display: inline-flex;
gap: var(--spacing-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--accent-primary);
}
.chipKey {
flex-shrink: 0;
}
.toolName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-amber);
}
.toolCall,
.toolResult {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.toolCall {
border-radius: var(--radius-2);
transition: outline-color 0.3s ease;
outline: 1px solid transparent;
outline-offset: var(--spacing-1);
&.isHighlighted {
outline-color: var(--primary);
}
// Large arguments scroll inside the block instead of stretching the card.
.json {
@include custom-scrollbar;
max-height: 240px;
overflow-y: auto;
}
}
.toolLink {
display: inline-flex;
align-self: flex-start;
max-width: 100%;
min-width: 0;
padding: 0;
border: none;
border-radius: var(--radius-2);
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
&:hover {
.chip {
background: var(--l3-background);
}
.toolLinkIcon {
color: var(--primary);
}
}
}
.toolLinkIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.collapsible {
min-width: 0;
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
gap: var(--spacing-2);
padding: 0 !important;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
// Unboxed: the chevron sits right after the label.
:global(.ant-collapse-header-text) {
flex: none !important;
margin-inline-end: 0 !important;
color: var(--l2-foreground);
@include paragraph-base;
}
:global(.ant-collapse-expand-icon) {
height: auto !important;
padding-inline-start: 0 !important;
}
:global(.ant-collapse-content-box) {
display: flex;
flex-direction: column;
min-width: 0;
padding: var(--spacing-4) 0 0 !important;
}
&.boxed {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-header) {
padding: var(--spacing-3) var(--spacing-5) !important;
}
:global(.ant-collapse-header-text) {
flex: auto !important;
}
:global(.ant-collapse-content-box) {
padding: 0 var(--spacing-5) var(--spacing-4) !important;
}
// The block is the box; nested text drops its own.
.textBox {
padding: 0;
border: none;
background: none;
}
}
}
.collapsibleChevron {
display: flex;
color: var(--l2-foreground);
}
.json {
min-width: 0;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
cursor: auto;
:global(.pretty-view) {
padding: 0;
}
}

View File

@@ -1,96 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { ThreadMessage } from './types';
import CollapsibleBlock from './CollapsibleBlock';
import MessagePart from './MessagePart';
import PartChip from './PartChip';
import RoleLabel from './RoleLabel';
import { getFinishReasonTone, getRoleLabel } from './utils';
import styles from './AIThreadMessage.module.scss';
export interface AIThreadMessageProps {
message: ThreadMessage;
isOutput?: boolean;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function AIThreadMessage({
message,
isOutput,
canLinkToToolCall,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, parts: messageParts, finish_reason: finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const parts = (
<div className={styles.parts}>
{messageParts.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
messageParts.map((part, index) => (
<MessagePart
// Parts have no stable id and never reorder.
// eslint-disable-next-line react/no-array-index-key
key={index}
part={part}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
))
)}
</div>
);
return (
<div
className={styles.message}
data-testid={`ai-thread-message-${role || 'unknown'}`}
>
{role === 'system' || role === 'developer' ? (
<CollapsibleBlock
label={roleLabel && <RoleLabel label={roleLabel} />}
testId="ai-message-system-toggle"
>
{parts}
</CollapsibleBlock>
) : (
<>
{roleLabel && (
<div className={styles.roleHeader}>
<RoleLabel label={roleLabel} />
{isOutput && (
<Badge
color={tone === 'info' ? 'robin' : tone}
testId="ai-message-output-badge"
>
Output
</Badge>
)}
</div>
)}
{parts}
</>
)}
{finishReason && (
<PartChip
label="Finish Reason"
value={finishReason}
tone={tone}
testId="ai-message-finish-reason"
/>
)}
</div>
);
}
AIThreadMessage.defaultProps = {
isOutput: false,
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default AIThreadMessage;

View File

@@ -1,54 +0,0 @@
import { MouseEvent, ReactNode } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import cx from 'classnames';
import styles from './AIThreadMessage.module.scss';
const PANEL_KEY = 'content';
interface CollapsibleBlockProps {
label: ReactNode;
// Boxed: full-width bordered row with the chevron on the right.
isBoxed?: boolean;
testId?: string;
children: ReactNode;
}
function CollapsibleBlock({
label,
isBoxed = false,
testId,
children,
}: CollapsibleBlockProps): JSX.Element {
// Toggling must not count as a click on the message card; the body still does.
const stopHeaderClick = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
event.stopPropagation();
}
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div onClick={stopHeaderClick}>
<Collapse
ghost
className={cx(styles.collapsible, isBoxed && styles.boxed)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.collapsibleChevron} data-testid={testId}>
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</span>
)}
items={[{ key: PANEL_KEY, label, children }]}
/>
</div>
);
}
CollapsibleBlock.defaultProps = {
isBoxed: false,
testId: undefined,
};
export default CollapsibleBlock;

View File

@@ -1,56 +0,0 @@
import { MouseEvent, useState } from 'react';
import cx from 'classnames';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { MAX_TEXT_LENGTH } from './utils';
import styles from './AIThreadMessage.module.scss';
interface ExpandableTextProps {
text: string;
isMarkdown?: boolean;
}
function ExpandableText({
text,
isMarkdown = false,
}: ExpandableTextProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<>
{isMarkdown ? (
<MarkdownRenderer
className={styles.text}
markdownContent={visibleText}
variables={{}}
/>
) : (
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
)}
{text.length > MAX_TEXT_LENGTH && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-message-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</>
);
}
ExpandableText.defaultProps = {
isMarkdown: false,
};
export default ExpandableText;

View File

@@ -1,26 +0,0 @@
import { PrettyView } from 'periscope/components/PrettyView';
import styles from './AIThreadMessage.module.scss';
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.json}
onClick={(event): void => event.stopPropagation()}
>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
/>
</div>
);
}
export default JsonBlock;

View File

@@ -1,89 +0,0 @@
import { MessagePart as MessagePartType } from './types';
import JsonBlock from './JsonBlock';
import ThinkingPart from './parts/ThinkingPart';
import ToolCallPart from './parts/ToolCallPart';
import ToolResultPart from './parts/ToolResultPart';
import TextContent from './TextContent';
import { toDisplayText, toToolCallKey, truncateBlobContent } from './utils';
interface MessagePartProps {
part: MessagePartType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function MessagePart({
part,
canLinkToToolCall,
onToolLinkClick,
}: MessagePartProps): JSX.Element | null {
switch (part.type) {
case 'text':
return <TextContent text={part.content} isMarkdown />;
case 'reasoning':
return <ThinkingPart content={part.content} />;
case 'compaction':
return (
<ThinkingPart
label="Compaction"
content={part.content ?? undefined}
testId="ai-message-compaction-toggle"
/>
);
case 'tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.arguments}
type={part.type}
/>
);
case 'server_tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.server_tool_call}
type={part.type}
/>
);
case 'tool_call_response':
case 'server_tool_call_response': {
const toolCallId = part.id ?? '';
const toolCallKey = toToolCallKey(
toolCallId,
part.type === 'server_tool_call_response',
);
return (
<ToolResultPart
toolCallId={toolCallId}
toolCallKey={toolCallKey}
type={part.type}
content={toDisplayText(
part.type === 'tool_call_response'
? part.response
: part.server_tool_call_response,
)}
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
onLinkClick={onToolLinkClick}
/>
);
}
case 'blob':
case 'file':
case 'uri':
return <JsonBlock data={truncateBlobContent(part)} />;
case 'generic':
return <TextContent text={toDisplayText(part.content)} />;
default:
return null;
}
}
MessagePart.defaultProps = {
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default MessagePart;

View File

@@ -1,52 +0,0 @@
import { ReactNode } from 'react';
import cx from 'classnames';
import { MessageTone } from './utils';
import styles from './AIThreadMessage.module.scss';
interface PartChipProps {
label: string;
value?: ReactNode;
tone?: MessageTone;
// Plain: no chip background, for headers like a tool call's.
isPlain?: boolean;
testId?: string;
children?: ReactNode;
}
function PartChip({
label,
value,
tone = 'success',
isPlain = false,
testId,
children,
}: PartChipProps): JSX.Element {
return (
<span
className={cx(styles.chip, isPlain && styles.isPlain)}
data-testid={testId}
>
<span className={styles.dot} data-tone={tone} />
<span className={styles.chipLabel}>{label}</span>
{value !== undefined && (
<>
<span className={styles.chipSeparator}>—</span>
<span className={styles.chipValue}>{value}</span>
</>
)}
{children}
</span>
);
}
PartChip.defaultProps = {
value: undefined,
tone: 'success',
isPlain: false,
testId: undefined,
children: undefined,
};
export default PartChip;

View File

@@ -1,18 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './AIThreadMessage.module.scss';
interface RoleLabelProps {
label: string;
}
/** Message role in the design's Paragraph/Base/400 style. */
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
return (
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
{label}
</Typography.Text>
);
}
export default RoleLabel;

View File

@@ -1,35 +0,0 @@
import { useMemo } from 'react';
import ExpandableText from './ExpandableText';
import JsonBlock from './JsonBlock';
import { parseJsonObject } from './utils';
import styles from './AIThreadMessage.module.scss';
interface TextContentProps {
text: string;
isMarkdown?: boolean;
}
/** Renders JSON text as a tree, anything else as (markdown) text. */
function TextContent({
text,
isMarkdown = false,
}: TextContentProps): JSX.Element {
const json = useMemo(() => parseJsonObject(text), [text]);
if (json) {
return <JsonBlock data={json} />;
}
return (
<div className={styles.textBox}>
<ExpandableText text={text} isMarkdown={isMarkdown} />
</div>
);
}
TextContent.defaultProps = {
isMarkdown: false,
};
export default TextContent;

View File

@@ -1,76 +0,0 @@
import { MessagePart, MessagePartType, ThreadMessage } from './types';
const PART_TYPES = new Set<string>([
'text',
'reasoning',
'compaction',
'tool_call',
'tool_call_response',
'server_tool_call',
'server_tool_call_response',
'blob',
'file',
'uri',
'generic',
] satisfies MessagePartType[]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function toPart(part: unknown): MessagePart {
if (typeof part === 'string') {
return { type: 'text', content: part };
}
if (
!isRecord(part) ||
typeof part.type !== 'string' ||
!PART_TYPES.has(part.type)
) {
return { type: 'generic', content: part };
}
switch (part.type) {
case 'text': {
// Some SDKs write "text" in place of the schema's "content".
const text = part.content ?? part.text;
return {
type: 'text',
content: typeof text === 'string' ? text : JSON.stringify(text ?? ''),
};
}
case 'reasoning':
return {
type: 'reasoning',
content: optionalString(part.content ?? part.text),
};
case 'generic':
return { type: 'generic', content: part.content ?? part };
default:
return part as MessagePart;
}
}
function toThreadMessage(item: unknown): ThreadMessage {
if (!isRecord(item)) {
return { parts: [toPart(item)] };
}
const body = item.parts ?? item.content;
return {
role: optionalString(item.role),
name: optionalString(item.name),
finish_reason: optionalString(item.finish_reason),
parts: Array.isArray(body) ? body.map(toPart) : [toPart(body ?? item)],
};
}
/** Normalises formatted or raw `gen_ai.*.messages` values into thread messages. */
export function toThreadMessages(value: unknown): ThreadMessage[] {
if (value === undefined || value === null) {
return [];
}
return (Array.isArray(value) ? value : [value]).map(toThreadMessage);
}

View File

@@ -1,34 +0,0 @@
import CollapsibleBlock from '../CollapsibleBlock';
import TextContent from '../TextContent';
import styles from '../AIThreadMessage.module.scss';
interface ThinkingPartProps {
content?: string;
label?: string;
testId?: string;
}
function ThinkingPart({
content,
label = 'Thinking',
testId = 'ai-message-thinking-toggle',
}: ThinkingPartProps): JSX.Element {
return (
<CollapsibleBlock label={label} isBoxed testId={testId}>
{content ? (
<TextContent text={content} isMarkdown />
) : (
<span className={styles.empty}>Content not captured</span>
)}
</CollapsibleBlock>
);
}
ThinkingPart.defaultProps = {
content: undefined,
label: 'Thinking',
testId: 'ai-message-thinking-toggle',
};
export default ThinkingPart;

View File

@@ -1,62 +0,0 @@
import { useMemo } from 'react';
import JsonBlock from '../JsonBlock';
import PartChip from '../PartChip';
import TextContent from '../TextContent';
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolCallPartProps {
id: string;
name: string;
args: unknown;
type: 'tool_call' | 'server_tool_call';
}
function ToolCallPart({
id,
name,
args,
type,
}: ToolCallPartProps): JSX.Element {
// Streamed calls can carry truncated, unparseable argument strings.
const json = useMemo(() => {
if (typeof args === 'string') {
return parseJsonObject(args);
}
return typeof args === 'object' && args !== null ? args : undefined;
}, [args]);
const toolCallAttr = {
[TOOL_CALL_ID_ATTR]: toToolCallKey(id, type === 'server_tool_call'),
};
return (
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
<PartChip
label={type}
tone="info"
isPlain
value={
<>
<span className={styles.chipKey}>name :</span>
<span className={styles.toolName}>{name}</span>
</>
}
/>
{json ? (
<JsonBlock data={json} />
) : (
args !== undefined &&
args !== null && (
<TextContent
text={typeof args === 'string' ? args : JSON.stringify(args)}
/>
)
)}
</div>
);
}
export default ToolCallPart;

View File

@@ -1,80 +0,0 @@
import { MouseEvent, useMemo } from 'react';
import { CornerUpRight } from '@signozhq/icons';
import PartChip from '../PartChip';
import TextContent from '../TextContent';
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolResultPartProps {
toolCallId: string;
toolCallKey: string;
type: 'tool_call_response' | 'server_tool_call_response';
name?: string;
content: string;
isError?: boolean;
isLinkEnabled: boolean;
onLinkClick?: (toolCallKey: string) => void;
}
function ToolResultPart({
toolCallId,
toolCallKey,
type,
name,
content,
isError,
isLinkEnabled,
onLinkClick,
}: ToolResultPartProps): JSX.Element {
// Short plain results fit in the chip; JSON or long ones render below it.
const isInline = useMemo(
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
[content],
);
const handleLinkClick = (event: MouseEvent): void => {
event.stopPropagation();
onLinkClick?.(toolCallKey);
};
const chip = (
<PartChip
label={type}
value={isInline ? content || '…' : name || toolCallId}
tone={isError ? 'error' : 'success'}
>
{isLinkEnabled && (
<CornerUpRight size={12} className={styles.toolLinkIcon} />
)}
</PartChip>
);
return (
<div className={styles.toolResult} data-testid="ai-tool-result">
{isLinkEnabled ? (
<button
type="button"
className={styles.toolLink}
onClick={handleLinkClick}
title="Go to tool call"
data-testid="ai-tool-result-link"
>
{chip}
</button>
) : (
chip
)}
{!isInline && <TextContent text={content} />}
</div>
);
}
ToolResultPart.defaultProps = {
name: undefined,
isError: false,
onLinkClick: undefined,
};
export default ToolResultPart;

View File

@@ -1,52 +0,0 @@
import type {
AiobservabilitytypesBlobPartDTO,
AiobservabilitytypesCompactionPartDTO,
AiobservabilitytypesFilePartDTO,
AiobservabilitytypesOutputMessageDTO,
AiobservabilitytypesReasoningPartDTO,
AiobservabilitytypesServerToolCallPartDTO,
AiobservabilitytypesServerToolCallResponsePartDTO,
AiobservabilitytypesTextPartDTO,
AiobservabilitytypesToolCallRequestPartDTO,
AiobservabilitytypesToolCallResponsePartDTO,
AiobservabilitytypesUriPartDTO,
} from 'api/generated/services/sigNoz.schemas';
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
type Part<T extends { type: string }> = Omit<T, 'type'> & {
type: `${T['type']}`;
};
// Keys the schema marks required may still be missing.
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
type: `${T['type']}`;
};
/** OTel GenAI message part; `text` content is always normalised to a string. */
export type MessagePart =
| Part<AiobservabilitytypesTextPartDTO>
| LoosePart<AiobservabilitytypesReasoningPartDTO>
| LoosePart<AiobservabilitytypesCompactionPartDTO>
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesBlobPartDTO>
| LoosePart<AiobservabilitytypesFilePartDTO>
| LoosePart<AiobservabilitytypesUriPartDTO>
| { type: 'generic'; content?: unknown };
export type MessagePartType = MessagePart['type'];
export type MediaMessagePart = Extract<
MessagePart,
{ type: 'blob' | 'file' | 'uri' }
>;
// role: system | developer | user | assistant | tool; any other value renders headerless.
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
export type ThreadMessage = Partial<
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
> & {
parts: MessagePart[];
};

View File

@@ -1,43 +0,0 @@
import { RefObject, useCallback } from 'react';
import { TOOL_CALL_ID_ATTR } from './utils';
import styles from './AIThreadMessage.module.scss';
const HIGHLIGHT_MS = 1500;
interface ToolCallLinks {
canLinkToToolCall: (toolCallKey: string) => boolean;
onToolLinkClick: (toolCallKey: string) => void;
}
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
export function useToolCallLinks(
rootRef: RefObject<HTMLElement>,
toolCallKeys: Set<string>,
): ToolCallLinks {
const canLinkToToolCall = useCallback(
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
[toolCallKeys],
);
const onToolLinkClick = useCallback(
(toolCallKey: string): void => {
const target = rootRef.current?.querySelector<HTMLElement>(
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
);
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add(styles.isHighlighted);
window.setTimeout(
() => target.classList.remove(styles.isHighlighted),
HIGHLIGHT_MS,
);
},
[rootRef],
);
return { canLinkToToolCall, onToolLinkClick };
}

View File

@@ -1,85 +0,0 @@
import { attempt, isError, isObjectLike } from 'lodash-es';
import { MediaMessagePart, ThreadMessage } from './types';
export const MAX_TEXT_LENGTH = 1000;
const MAX_BLOB_PREVIEW_LENGTH = 100;
export const MAX_INLINE_RESULT_LENGTH = 80;
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
const ROLE_LABELS: Record<string, string> = {
system: 'System',
developer: 'Developer',
user: 'User',
assistant: 'Assistant',
tool: 'Tool',
};
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
// Provider values the backend could not map fall back to info.
const FINISH_REASON_TONES: Record<string, MessageTone> = {
stop: 'success',
tool_call: 'success',
length: 'warning',
content_filter: 'warning',
compaction: 'info',
error: 'error',
};
/** Undefined for missing or unknown roles, which render headerless. */
export function getRoleLabel(role?: string): string | undefined {
return role ? ROLE_LABELS[role] : undefined;
}
export function getFinishReasonTone(reason?: string | null): MessageTone {
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
}
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
export function parseJsonObject(text: string): object | undefined {
const parsed: unknown = attempt(JSON.parse, text);
return isObjectLike(parsed) && !isError(parsed)
? (parsed as object)
: undefined;
}
export function toDisplayText(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
/** Blob content is inline base64; only a prefix is shown. */
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
if (
part.type !== 'blob' ||
!part.content ||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
) {
return part;
}
return {
...part,
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
};
}
// Server and client calls can share an id, so links match on kind too.
export function toToolCallKey(id: string, isServer: boolean): string {
return `${isServer ? 'server' : 'client'}:${id}`;
}
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
return messages.flatMap((message) =>
message.parts.flatMap((part) =>
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
: [],
),
);
}

View File

@@ -7,9 +7,3 @@
cursor: default;
color: inherit;
}
// Above the floating span details panel (z-index 999).
.tooltip {
composes: tooltipContent from '../shared.module.scss';
--tooltip-z-index: 1000;
}

View File

@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: TooltipSimpleProps['title'];
tooltip: string;
icon?: ReactNode;
children: ReactNode;
}
@@ -16,10 +16,7 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TooltipSimple
title={tooltip}
tooltipContentProps={{ className: styles.tooltip }}
>
<TooltipSimple title={tooltip}>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>

View File

@@ -8,12 +8,3 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
composes: tooltipBody from '../shared.module.scss';
min-width: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,18 +1,10 @@
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import {
AiTokenCounts,
formatCost,
formatTokens,
getTotalInputTokens,
hasValue,
} from '../utils/aiTraceUtils';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -24,8 +16,6 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -42,11 +32,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -104,24 +91,6 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -133,8 +102,6 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -1,35 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/aiTraceUtils';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="normal"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -1,43 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.accordion {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
height: 40px;
padding: 0 var(--spacing-4) !important;
}
:global(.ant-collapse-content) {
background: transparent;
border-top: 1px solid var(--l2-border);
}
:global(.ant-collapse-content-box) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.chevron {
display: flex;
color: var(--l2-foreground);
}

View File

@@ -1,103 +0,0 @@
import { MouseEvent, ReactNode, useState } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../SpanMessages/utils';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
import styles from './MessagesAccordion.module.scss';
const PANEL_KEY = 'messages';
interface MessagesAccordionProps extends Pick<
AIThreadMessageProps,
'isOutput' | 'canLinkToToolCall' | 'onToolLinkClick'
> {
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
isOutput,
canLinkToToolCall,
onToolLinkClick,
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (!canFormat && !raw) {
return null;
}
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
return (
<section data-testid={testId}>
<Collapse
className={styles.accordion}
activeKey={isOpen ? [PANEL_KEY] : []}
onChange={(): void => setIsOpen((prev) => !prev)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
)}
items={[
{
key: PANEL_KEY,
label: (
<span className={styles.title}>
{icon}
{title}
</span>
),
extra: isOpen &&
canFormat && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={effectiveView}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
</span>
),
children: (
<MessagesSection
view={effectiveView}
messages={messages}
raw={raw}
testId={`${testId}-body`}
isOutput={isOutput}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
),
},
]}
/>
</section>
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -1,7 +0,0 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex-shrink: 0;
min-width: 0;
}

View File

@@ -1,56 +0,0 @@
import { useMemo, useRef } from 'react';
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../SpanMessages/utils';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
import styles from './SpanAIMessages.module.scss';
interface SpanAIMessagesProps {
span: ThreadSpan;
}
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
const data = useMemo(() => getSpanMessages(span), [span]);
const rootRef = useRef<HTMLDivElement>(null);
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
rootRef,
toolCallKeys,
);
if (!hasAnyMessages(data)) {
return null;
}
return (
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
<MessagesAccordion
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesAccordion
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
isOutput
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
export default SpanAIMessages;

View File

@@ -52,7 +52,6 @@ import Events from './Events/Events';
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -292,7 +291,6 @@ function SpanDetailsContent({
<>
<div className={styles.panelBody} ref={bodyRef}>
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
<SpanAIMessages span={selectedSpan} />
<div className={styles.tabsSection}>
{/* Step 9: ContentTabs */}

View File

@@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -33,10 +31,6 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -69,8 +63,6 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,26 +95,4 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

View File

@@ -1,37 +0,0 @@
import { CSSProperties } from 'react';
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/aiTraceUtils';
import { AI_SPAN_BADGES } from './constants';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<Badge
variant="outline"
// The outline variant derives text, fill and border from this color.
style={{ '--badge-background': color } as CSSProperties}
data-testid={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -1,10 +0,0 @@
import { AiSpanKind } from '../utils/aiTraceUtils';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: string }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
};

View File

@@ -8,12 +8,16 @@
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {

View File

@@ -9,24 +9,40 @@
}
.popover {
composes: tooltipContent from '../shared.module.scss';
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}
.content {
composes: tooltipBody from '../shared.module.scss';
min-width: 223px;
max-width: 360px;
font-size: 12px;
color: var(--l1-foreground);
}
.title {
composes: tooltipTitle from '../shared.module.scss';
.name {
font-weight: 600;
margin-bottom: 4px;
}
.header {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-2);
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
}

Some files were not shown because too many files have changed in this diff Show More