mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-28 22:30:43 +01:00
Compare commits
6 Commits
test/inter
...
feat/explo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6261444d4c | ||
|
|
6bf358a9f3 | ||
|
|
dc09ddbf0f | ||
|
|
e93301b7e6 | ||
|
|
433a221866 | ||
|
|
8687b38e19 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -38,6 +38,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
@@ -63,7 +64,6 @@ jobs:
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- ruler
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
|
||||
@@ -23,10 +23,6 @@ const IGNORED_MESSAGES = [
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
// The filter editor's ANTLR parser reports every syntax error through
|
||||
// `console.error` (`line 1:14 missing ...`), so each partial expression
|
||||
// typed into it logs one; the editor shows the same errors on screen.
|
||||
/^line \d+:\d+ /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
|
||||
@@ -106,7 +106,7 @@ describe.each([
|
||||
renderWithStore(dataSource);
|
||||
const button = screen.getByTestId(testId);
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('periscope-btn', 'ghost');
|
||||
expect(button).toHaveAccessibleName('Download');
|
||||
});
|
||||
|
||||
it('shows popover with export options when download button is clicked', () => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useExportRawData } from 'hooks/useExportData/useServerExport';
|
||||
import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
@@ -111,8 +112,9 @@ export default function DownloadOptionsMenu({
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download size={16} />}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Download size={16} />}
|
||||
onClick={handleExport}
|
||||
className="export-button"
|
||||
disabled={isDownloading}
|
||||
@@ -144,16 +146,14 @@ export default function DownloadOptionsMenu({
|
||||
>
|
||||
<Tooltip title="Download" placement="top">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={
|
||||
isDownloading ? (
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)
|
||||
}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Download size={14} />}
|
||||
aria-label="Download"
|
||||
data-testid={`periscope-btn-download-${dataSource}`}
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The editor is controlled: each change round-trips through React state before
|
||||
* the next one is applied on top of it. People type slower than this.
|
||||
*/
|
||||
const KEYSTROKE_MS = 50;
|
||||
|
||||
/** Throws until `found` holds something, which is what `waitFor` retries on. */
|
||||
const present = <TValue>(
|
||||
found: TValue | null | undefined,
|
||||
what: string,
|
||||
): TValue => {
|
||||
if (found === null || found === undefined) {
|
||||
throw new Error(`${what} not found`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const pause = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
|
||||
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
|
||||
|
||||
const suggestionRow = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): HTMLElement | undefined => {
|
||||
const list = suggestionList(canvasElement);
|
||||
|
||||
return list
|
||||
? within(list)
|
||||
.queryAllByRole('option')
|
||||
.find((option) => option.textContent?.includes(text))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
|
||||
const requestSuggestions = (editor: HTMLElement): void => {
|
||||
editor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const currentView = (
|
||||
canvasElement: HTMLElement,
|
||||
): { editor: HTMLElement; view: EditorView } => {
|
||||
// An explorer renders one editor per query; the first is the one on screen.
|
||||
const editor = present(
|
||||
canvasElement.querySelector<HTMLElement>(
|
||||
'.code-mirror-where-clause .cm-content',
|
||||
),
|
||||
'filter editor',
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
view: present(EditorView.findFromDOM(editor), 'editor view'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
|
||||
* the browser is idle. Initialising moves the content, the editor with it, and
|
||||
* focuses the editor again through the DOM, which puts the caret back at the
|
||||
* start and swaps the suggestions for the key list. Throws until every wrapper
|
||||
* around the filter has initialised.
|
||||
*/
|
||||
const assertScrollbarsReady = (editor: HTMLElement): void => {
|
||||
for (
|
||||
let wrapper = editor.closest('.overlay-scrollbar');
|
||||
wrapper;
|
||||
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
|
||||
) {
|
||||
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
|
||||
throw new Error('scrollbars around the filter still initialising');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until `text` shows in the suggestion list, asking for suggestions
|
||||
* whenever the list is shut. Focus and typing only open it once the keys have
|
||||
* loaded, and moving the caret never does.
|
||||
*/
|
||||
const waitForSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> =>
|
||||
waitFor(
|
||||
() => {
|
||||
const { editor } = currentView(canvasElement);
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
/**
|
||||
* Focuses the filter once the scrollbars around it have initialised, and waits
|
||||
* for its suggestion list.
|
||||
*/
|
||||
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { editor, view } = currentView(canvasElement);
|
||||
|
||||
assertScrollbarsReady(editor);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
view.focus();
|
||||
}
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionList(canvasElement), 'suggestion list');
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
return currentView(canvasElement).view;
|
||||
};
|
||||
|
||||
/** Waits for a row of the suggestion list. */
|
||||
export const findSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
|
||||
|
||||
/** Focuses the empty filter: every key, with any recent filters above them. */
|
||||
export const openKeySuggestions = async (
|
||||
canvasElement: HTMLElement,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await focusFilter(canvasElement);
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses the filter and types onto the end of it one character at a time,
|
||||
* each as the transaction a keystroke makes, leaving the caret at the end so
|
||||
* the suggestion list follows what was typed. Quotes and brackets are not
|
||||
* closed for it: type both.
|
||||
*
|
||||
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
|
||||
* highlighted, which strands the caret `userEvent` tracks.
|
||||
*/
|
||||
export const typeFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const view = await focusFilter(canvasElement);
|
||||
|
||||
for (const character of text) {
|
||||
const at = view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: at, insert: character },
|
||||
selection: { anchor: at + character.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
await pause(KEYSTROKE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Types an expression, then steps the caret back inside it, before a closing
|
||||
* bracket or parenthesis, where the suggestions are about what goes in there.
|
||||
*/
|
||||
export const typeFilterWithCaretBack = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
stepsBack: number,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
|
||||
const { view } = currentView(canvasElement);
|
||||
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.doc.length - stepsBack },
|
||||
userEvent: 'select',
|
||||
});
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves focus off the filter, which is when the expression is validated and
|
||||
* the error marker can show.
|
||||
*/
|
||||
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
};
|
||||
|
||||
/** Types an expression, leaves the filter and opens its validation errors. */
|
||||
export const showFilterErrors = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
await blurFilter(canvasElement);
|
||||
|
||||
const marker = await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
|
||||
'error marker',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.hover(marker);
|
||||
await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.ownerDocument.querySelector('.query-validation-error'),
|
||||
'validation error',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -47,5 +47,4 @@ export enum LOCALSTORAGE {
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
|
||||
}
|
||||
|
||||
@@ -3,22 +3,15 @@ import {
|
||||
MessageActionKindDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import type { History } from 'history';
|
||||
|
||||
import {
|
||||
@@ -38,7 +31,8 @@ import {
|
||||
} from '../resolveOpenResource';
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
jest.mock('api/saveView/getAllViews');
|
||||
jest.mock('api/saveView/getViewById');
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
@@ -54,45 +48,43 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockedListSavedViews = listSavedViews as jest.MockedFunction<
|
||||
typeof listSavedViews
|
||||
const mockedGetAllViews = getAllViews as jest.MockedFunction<
|
||||
typeof getAllViews
|
||||
>;
|
||||
const mockedGetSavedView = getSavedView as jest.MockedFunction<
|
||||
typeof getSavedView
|
||||
const mockedGetViewById = getViewById as jest.MockedFunction<
|
||||
typeof getViewById
|
||||
>;
|
||||
|
||||
function makeView(
|
||||
id: string,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
function makeView(id: string, sourcePage: DataSource): ViewProps {
|
||||
return {
|
||||
id,
|
||||
name: `view-${id}`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
name: `View ${id}`,
|
||||
category: 'test',
|
||||
createdAt: '2021-07-07T06:31:00.000Z',
|
||||
createdBy: 'user',
|
||||
updatedAt: '2021-07-07T06:33:00.000Z',
|
||||
updatedBy: 'user',
|
||||
spec: {
|
||||
displayName: `View ${id}`,
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [{ type: 'builder_query', spec: { name: 'A', signal: source } }],
|
||||
},
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
sourcePage,
|
||||
tags: [],
|
||||
extraData: '',
|
||||
compositeQuery: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
} as ICompositeMetricQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function mockViewsResponse(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
): ListSavedViews200 {
|
||||
return { status: 'success', data: views };
|
||||
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
|
||||
return {
|
||||
data: { status: 'success', data: views },
|
||||
} as AxiosResponse<AllViewsProps>;
|
||||
}
|
||||
|
||||
function mockViewByIdResponse(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 {
|
||||
return { status: 'success', data: view };
|
||||
view: ViewProps,
|
||||
): AxiosResponse<{ status: string; data: ViewProps }> {
|
||||
return {
|
||||
data: { status: 'success', data: view },
|
||||
} as AxiosResponse<{ status: string; data: ViewProps }>;
|
||||
}
|
||||
|
||||
describe('resourceRoute', () => {
|
||||
@@ -198,33 +190,18 @@ describe('resolveOpenResource', () => {
|
||||
|
||||
describe('findSavedViewInLists', () => {
|
||||
beforeEach(() => {
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetAllViews.mockReset();
|
||||
});
|
||||
|
||||
it('loads only the hinted source when entity is provided', async () => {
|
||||
const tracesView = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
const tracesView = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(result).toStrictEqual(tracesView);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a null list as empty and probes the next source', async () => {
|
||||
const metricsView = makeView('view-metrics', SavedviewtypesSourceDTO.metrics);
|
||||
mockedListSavedViews
|
||||
.mockResolvedValueOnce({ status: 'success', data: null })
|
||||
.mockResolvedValueOnce(mockViewsResponse([]))
|
||||
.mockResolvedValueOnce(mockViewsResponse([metricsView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-metrics');
|
||||
|
||||
expect(result).toStrictEqual(metricsView);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(3);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,75 +227,52 @@ describe('openSavedView', () => {
|
||||
it('navigates with history.push and view query params', () => {
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
|
||||
openSavedView(view, history);
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = push.mock.calls[0][0] as string;
|
||||
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1]);
|
||||
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
|
||||
expect(params.get(QueryParams.viewName)).toBe('"View view-logs"');
|
||||
expect(params.get(QueryParams.panelTypes)).toBe('"list"');
|
||||
});
|
||||
|
||||
it('throws when the view has no source', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
delete view.source;
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Unsupported saved view source');
|
||||
});
|
||||
|
||||
it('throws when the view has no queries', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
view.spec.queries = [];
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Saved view is missing query data');
|
||||
expect(pushedUrl).toContain(QueryParams.viewKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetSavedView.mockReset();
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedGetViewById.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
mockedGetSavedView.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
|
||||
|
||||
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
|
||||
expect(mockedListSavedViews).not.toHaveBeenCalled();
|
||||
expect(mockedGetViewById).toHaveBeenCalledWith('view-logs');
|
||||
expect(mockedGetAllViews).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to list probing when direct lookup fails', async () => {
|
||||
const view = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const view = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
|
||||
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { History } from 'history';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
type SavedViewSourceHint = DataSource | 'meter';
|
||||
|
||||
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
DataSource.LOGS,
|
||||
@@ -27,15 +20,13 @@ const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
export async function findSavedViewInLists(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<SavedviewtypesSavedViewDTO | null> {
|
||||
): Promise<ViewProps | null> {
|
||||
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await listSavedViews({
|
||||
source: toSavedViewSource(source),
|
||||
});
|
||||
const match = findSavedView(response.data, viewKey);
|
||||
const response = await getAllViews(source);
|
||||
const match = response.data.data.find((view) => view.id === viewKey);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
@@ -50,11 +41,11 @@ export async function findSavedViewInLists(
|
||||
async function loadSavedView(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<SavedviewtypesSavedViewDTO> {
|
||||
): Promise<ViewProps> {
|
||||
try {
|
||||
const response = await getSavedView({ id: viewKey });
|
||||
if (response.data) {
|
||||
return response.data;
|
||||
const response = await getViewById(viewKey);
|
||||
if (response.data?.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to list probing when the direct lookup fails.
|
||||
@@ -94,23 +85,20 @@ export function buildExplorerNavigationUrl(
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
history: History,
|
||||
): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
export function openSavedView(view: ViewProps, history: History): void {
|
||||
const route = explorerRouteForSourcePage(view.sourcePage);
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
}
|
||||
|
||||
if (!view.spec.queries?.length) {
|
||||
if (!view.compositeQuery) {
|
||||
throw new Error('Saved view is missing query data');
|
||||
}
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
const query = mapQueryDataFromApi(view.compositeQuery);
|
||||
const url = buildExplorerNavigationUrl(route, query, {
|
||||
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.spec.displayName,
|
||||
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.name,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
@@ -124,3 +112,6 @@ export async function openSavedViewByKey(
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
}
|
||||
|
||||
/** @deprecated Use findSavedViewInLists — kept for tests. */
|
||||
export const findSavedView = findSavedViewInLists;
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
z-index: 0;
|
||||
background: var(--l1-background);
|
||||
|
||||
// Column so the bottom strip sits under the scrolling content, not inside it.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.full-screen-content {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -74,9 +70,7 @@
|
||||
|
||||
.chat-support-gateway {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: calc(20px + var(--bottom-strip-height, 0px));
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
|
||||
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import BottomStrip from 'container/BottomStrip';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -52,7 +51,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import useTabVisibility from 'hooks/useTabFocus';
|
||||
import history from 'lib/history';
|
||||
import { isNull } from 'lodash-es';
|
||||
@@ -404,7 +402,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [pathname]);
|
||||
|
||||
const isToDisplayLayout = isLoggedIn;
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
@@ -871,10 +868,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</OverlayScrollbar>
|
||||
</LayoutContent>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
|
||||
<BottomStrip />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoggedIn && isAIAssistantEnabled && (
|
||||
|
||||
@@ -12,12 +12,8 @@ export const Layout = styled(LayoutComponent)`
|
||||
}
|
||||
`;
|
||||
|
||||
// Takes the height left in `.app-content` after the bottom strip.
|
||||
// `min-height: 0` is not needed right now, overlayscrollbars already sets
|
||||
// `overflow: auto` here. Kept so this does not break if that goes away.
|
||||
export const LayoutContent = styled(LayoutComponent.Content)`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
flex-shrink: 0;
|
||||
height: var(--bottom-strip-height);
|
||||
padding: 0 var(--spacing-6);
|
||||
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
// cover the strip.
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
|
||||
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
// The string is whatever the Go build injected, so it is rendered untouched —
|
||||
// same as SideNav. Release tags carry the "v", local builds do not.
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
export const BOTTOM_STRIP_HEIGHT = 24;
|
||||
|
||||
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
`${BOTTOM_STRIP_HEIGHT}px`,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomStrip;
|
||||
@@ -1,8 +1,6 @@
|
||||
.create-alert-v2-footer {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
left: 63px;
|
||||
right: 0;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid2X2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExportPanelContainer from 'container/ExportPanel/ExportPanelContainer';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { EXPLORER_ACTION_EVENTS, getExportPanelType } from './utils';
|
||||
|
||||
function AddToDashboardButton({
|
||||
query,
|
||||
sourcepage,
|
||||
panelType,
|
||||
}: {
|
||||
query: Query | null;
|
||||
sourcepage: DataSource;
|
||||
panelType?: PANEL_TYPES;
|
||||
}): JSX.Element {
|
||||
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
|
||||
const { panelType: contextPanelType } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const open = (): void => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
void logEvent(EXPLORER_ACTION_EVENTS.addToDashboard, {
|
||||
sourcepage,
|
||||
panelType: contextPanelType,
|
||||
});
|
||||
setQueryToExport(query);
|
||||
};
|
||||
|
||||
const handleExport = (
|
||||
dashboard: ExportDashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
): void => {
|
||||
if (!dashboard || !queryToExport) {
|
||||
return;
|
||||
}
|
||||
const exportPanelType = panelType ?? getExportPanelType(contextPanelType);
|
||||
|
||||
void logEvent(EXPLORER_ACTION_EVENTS.exported, {
|
||||
sourcepage,
|
||||
panelType: exportPanelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
|
||||
const link = getExportToDashboardLink({
|
||||
query: queryToExport,
|
||||
panelType: exportPanelType,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId: v4(),
|
||||
});
|
||||
if (link) {
|
||||
safeNavigate(link);
|
||||
}
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={!query}
|
||||
onClick={open}
|
||||
prefix={<Grid2X2 size={16} />}
|
||||
aria-label="Add to dashboard"
|
||||
data-testid="explorer-add-to-dashboard"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSimple title="Add to dashboard">{button}</TooltipSimple>
|
||||
<ExportPanelContainer
|
||||
open={queryToExport !== null}
|
||||
onClose={(): void => setQueryToExport(null)}
|
||||
query={queryToExport}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddToDashboardButton;
|
||||
54
frontend/src/container/ExplorerActions/CreateAlertButton.tsx
Normal file
54
frontend/src/container/ExplorerActions/CreateAlertButton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ConciergeBell } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { EXPLORER_ACTION_EVENTS, getCreateAlertLink } from './utils';
|
||||
|
||||
function CreateAlertButton({
|
||||
query,
|
||||
sourcepage,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
query: Query | null;
|
||||
sourcepage: DataSource;
|
||||
iconOnly?: boolean;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { panelType } = useQueryBuilder();
|
||||
|
||||
const createAlert = (): void => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
void logEvent(EXPLORER_ACTION_EVENTS.createAlert, { sourcepage, panelType });
|
||||
history.push(getCreateAlertLink({ query, panelType }));
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size={iconOnly ? 'icon' : 'md'}
|
||||
disabled={!query}
|
||||
onClick={createAlert}
|
||||
prefix={<ConciergeBell size={16} />}
|
||||
aria-label="Create an alert"
|
||||
data-testid="explorer-create-alert"
|
||||
>
|
||||
{!iconOnly && 'Create an alert'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return iconOnly ? (
|
||||
<TooltipSimple title="Create an alert">{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAlertButton;
|
||||
38
frontend/src/container/ExplorerActions/ExplorerActions.tsx
Normal file
38
frontend/src/container/ExplorerActions/ExplorerActions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddToDashboardButton from './AddToDashboardButton';
|
||||
import CreateAlertButton from './CreateAlertButton';
|
||||
|
||||
function ExplorerActions({
|
||||
query,
|
||||
dashboardQuery = query,
|
||||
sourcepage,
|
||||
panelType,
|
||||
iconOnly,
|
||||
}: {
|
||||
query: Query | null;
|
||||
// When the dashboard export differs from the alert one (traces list injects columns).
|
||||
dashboardQuery?: Query | null;
|
||||
sourcepage: DataSource;
|
||||
panelType?: PANEL_TYPES;
|
||||
iconOnly?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<CreateAlertButton
|
||||
query={query}
|
||||
sourcepage={sourcepage}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
<AddToDashboardButton
|
||||
query={dashboardQuery}
|
||||
sourcepage={sourcepage}
|
||||
panelType={panelType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExplorerActions;
|
||||
@@ -0,0 +1,317 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getExportQueryData as getLogsExportQuery,
|
||||
getQueryByPanelType as getLogsQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import {
|
||||
getExportQueryData as getTracesExportQuery,
|
||||
getQueryByPanelType as getTracesQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import AddToDashboardButton from '../AddToDashboardButton';
|
||||
import { EXPLORER_ACTION_EVENTS, getExportPanelType } from '../utils';
|
||||
|
||||
const DASHBOARD = { id: 'dash-1', title: 'Dash 1' };
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: jest.fn(),
|
||||
}));
|
||||
jest.mock('uuid', () => ({ v4: (): string => 'widget-1' }));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// The picker is the dialog's business; here it just hands a dashboard back.
|
||||
jest.mock('container/ExportPanel/ExportPanelContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
open,
|
||||
query,
|
||||
onExport,
|
||||
}: {
|
||||
open: boolean;
|
||||
query: Query | null;
|
||||
onExport: (dashboard: { id: string; title: string }) => void;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="export-stub"
|
||||
data-query={JSON.stringify(query)}
|
||||
onClick={(): void => onExport({ id: 'dash-1', title: 'Dash 1' })}
|
||||
>
|
||||
export
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedUseSafeNavigate = jest.mocked(useSafeNavigate);
|
||||
const mockedLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
const COLUMNS = [{ name: 'service.name' }, { name: 'name' }];
|
||||
const options = { selectColumns: COLUMNS } as unknown as OptionsQuery;
|
||||
|
||||
function stagedQuery(dataSource: DataSource, queryName = 'A'): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
id: `query-${queryName}`,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
queryName,
|
||||
aggregateOperator: StringOperators.COUNT,
|
||||
filter: { expression: FILTER },
|
||||
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function setPanelType(panelType: PANEL_TYPES): void {
|
||||
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>);
|
||||
}
|
||||
|
||||
async function exportTo(
|
||||
query: Query | null,
|
||||
sourcepage: DataSource,
|
||||
panelType: PANEL_TYPES,
|
||||
panelTypeProp?: PANEL_TYPES,
|
||||
): Promise<void> {
|
||||
setPanelType(panelType);
|
||||
render(
|
||||
<AddToDashboardButton
|
||||
query={query}
|
||||
sourcepage={sourcepage}
|
||||
panelType={panelTypeProp}
|
||||
/>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('explorer-add-to-dashboard'));
|
||||
await user.click(screen.getByTestId('export-stub'));
|
||||
}
|
||||
|
||||
function expectedLink(query: Query, panelType: PANEL_TYPES): string | null {
|
||||
return buildExportPanelLink({
|
||||
query,
|
||||
panelType,
|
||||
dashboardId: DASHBOARD.id,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AddToDashboardButton', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockReset();
|
||||
mockedLogEvent.mockClear();
|
||||
mockedUseSafeNavigate.mockReturnValue({ safeNavigate: mockSafeNavigate });
|
||||
});
|
||||
|
||||
it('is disabled without a query and the picker stays closed', () => {
|
||||
setPanelType(PANEL_TYPES.LIST);
|
||||
render(<AddToDashboardButton query={null} sourcepage={DataSource.LOGS} />);
|
||||
|
||||
expect(screen.getByTestId('explorer-add-to-dashboard')).toBeDisabled();
|
||||
expect(screen.queryByTestId('export-stub')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the picker the same query it will export', async () => {
|
||||
const query = stagedQuery(DataSource.LOGS);
|
||||
setPanelType(PANEL_TYPES.TIME_SERIES);
|
||||
render(<AddToDashboardButton query={query} sourcepage={DataSource.LOGS} />);
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId('explorer-add-to-dashboard'));
|
||||
|
||||
expect(screen.getByTestId('export-stub')).toHaveAttribute(
|
||||
'data-query',
|
||||
JSON.stringify(query),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs open and success with the source page', async () => {
|
||||
const query = stagedQuery(DataSource.TRACES);
|
||||
|
||||
await exportTo(query, DataSource.TRACES, PANEL_TYPES.TABLE);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
EXPLORER_ACTION_EVENTS.addToDashboard,
|
||||
{
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(EXPLORER_ACTION_EVENTS.exported, {
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
isNewDashboard: undefined,
|
||||
dashboardName: DASHBOARD.title,
|
||||
});
|
||||
});
|
||||
|
||||
it('a panel type from the page wins over the fold of the context one', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS);
|
||||
|
||||
// context says list, the page says time series
|
||||
await exportTo(
|
||||
query,
|
||||
DataSource.METRICS,
|
||||
PANEL_TYPES.LIST,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(query, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
|
||||
describe('logs, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.LOGS);
|
||||
|
||||
it('list: the list request shaping with timestamp desc, panel type list', async () => {
|
||||
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: FILTER },
|
||||
});
|
||||
const exportQuery = getLogsExportQuery(
|
||||
listRequest,
|
||||
PANEL_TYPES.LIST,
|
||||
) as Query;
|
||||
|
||||
await exportTo(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.LIST),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query untouched, same panel type',
|
||||
async (panelType) => {
|
||||
const exportQuery = getLogsExportQuery(staged, panelType) as Query;
|
||||
|
||||
await exportTo(exportQuery, DataSource.LOGS, panelType);
|
||||
|
||||
expect(exportQuery).toBe(staged);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(staged, panelType),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('traces, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.TRACES);
|
||||
|
||||
it('list: list shaping plus the selected columns, panel type list', async () => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, PANEL_TYPES.LIST),
|
||||
getExportPanelType(PANEL_TYPES.LIST),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.LIST);
|
||||
|
||||
const [queryData] = exportQuery.builder.queryData;
|
||||
expect(queryData.selectColumns).toStrictEqual(COLUMNS);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.LIST),
|
||||
);
|
||||
});
|
||||
|
||||
it('trace: list shaping, no columns, panel type folds to time series', async () => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, PANEL_TYPES.TRACE),
|
||||
getExportPanelType(PANEL_TYPES.TRACE),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, PANEL_TYPES.TRACE);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].selectColumns).toBeUndefined();
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
|
||||
// Same as the alert: the list / trace order lives in ListView state and the
|
||||
// page shapes the export without it, so the panel query has no order by.
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: order by is not carried into the panel query',
|
||||
async (panelType) => {
|
||||
expect(staged.builder.queryData[0].orderBy).toHaveLength(1);
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, panelType),
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(exportQuery.builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(exportQuery, getExportPanelType(panelType)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query untouched, same panel type',
|
||||
async (panelType) => {
|
||||
const exportQuery = getTracesExportQuery(
|
||||
getTracesQueryByPanelType(staged, panelType),
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
);
|
||||
|
||||
await exportTo(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(exportQuery).toBe(staged);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(staged, panelType),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('metrics: the chart query as is, panel type time series from the page', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS);
|
||||
|
||||
await exportTo(
|
||||
query,
|
||||
DataSource.METRICS,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expectedLink(query, PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
getExportQueryData as getLogsExportQuery,
|
||||
getQueryByPanelType as getLogsQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { getQueryByPanelType as getTracesQueryByPanelType } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import CreateAlertButton from '../CreateAlertButton';
|
||||
import { EXPLORER_ACTION_EVENTS } from '../utils';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const mockPush = jest.fn();
|
||||
const mockedUseHistory = jest.mocked(useHistory);
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const FILTER = "service.name = 'frontend'";
|
||||
const ORDER_BY = [{ columnName: 'timestamp', order: 'asc' }];
|
||||
|
||||
function stagedQuery(
|
||||
dataSource: DataSource,
|
||||
aggregateOperator: StringOperators,
|
||||
queryName = 'A',
|
||||
): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
id: `query-${queryName}`,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
queryName,
|
||||
aggregateOperator,
|
||||
filter: { expression: FILTER },
|
||||
orderBy: ORDER_BY,
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
function pushedQuery(): Query {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
const [path, search] = (mockPush.mock.calls[0][0] as string).split('?');
|
||||
expect(path).toBe(ROUTES.ALERTS_NEW);
|
||||
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
|
||||
return JSON.parse(raw as string);
|
||||
}
|
||||
|
||||
function setPanelType(panelType: PANEL_TYPES): void {
|
||||
mockedUseQueryBuilder.mockReturnValue({ panelType } as ReturnType<
|
||||
typeof useQueryBuilder
|
||||
>);
|
||||
}
|
||||
|
||||
async function clickCreateAlert(
|
||||
query: Query | null,
|
||||
sourcepage: DataSource,
|
||||
panelType: PANEL_TYPES,
|
||||
): Promise<void> {
|
||||
setPanelType(panelType);
|
||||
render(<CreateAlertButton query={query} sourcepage={sourcepage} />);
|
||||
await userEvent.setup().click(screen.getByTestId('explorer-create-alert'));
|
||||
}
|
||||
|
||||
describe('CreateAlertButton', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset();
|
||||
mockedLogEvent.mockClear();
|
||||
mockedUseHistory.mockReturnValue({ push: mockPush } as unknown as ReturnType<
|
||||
typeof useHistory
|
||||
>);
|
||||
});
|
||||
|
||||
it('is disabled and does nothing without a query', async () => {
|
||||
await clickCreateAlert(null, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
expect(screen.getByTestId('explorer-create-alert')).toBeDisabled();
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs one event with the source page', async () => {
|
||||
const query = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
|
||||
|
||||
await clickCreateAlert(query, DataSource.TRACES, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(mockedLogEvent).toHaveBeenCalledWith(
|
||||
EXPLORER_ACTION_EVENTS.createAlert,
|
||||
{
|
||||
sourcepage: DataSource.TRACES,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('logs, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.LOGS, StringOperators.NOOP);
|
||||
|
||||
it('list: count aggregation, no order by, filter and pagination as the page sent them', async () => {
|
||||
const listRequest = getLogsQueryByPanelType(staged, PANEL_TYPES.LIST, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: FILTER },
|
||||
});
|
||||
const exportQuery = getLogsExportQuery(
|
||||
listRequest,
|
||||
PANEL_TYPES.LIST,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.LIST);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.filter).toStrictEqual({ expression: FILTER });
|
||||
expect(queryData.pageSize).toBe(100);
|
||||
});
|
||||
|
||||
it('time series: staged query as is, order by and group by kept', async () => {
|
||||
const tsStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
|
||||
const exportQuery = getLogsExportQuery(
|
||||
tsStaged,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(
|
||||
exportQuery,
|
||||
DataSource.LOGS,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData).toStrictEqual(tsStaged.builder.queryData[0]);
|
||||
});
|
||||
|
||||
it('table: staged query as is', async () => {
|
||||
const tableStaged = stagedQuery(DataSource.LOGS, StringOperators.COUNT);
|
||||
const exportQuery = getLogsExportQuery(
|
||||
tableStaged,
|
||||
PANEL_TYPES.TABLE,
|
||||
) as Query;
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.LOGS, PANEL_TYPES.TABLE);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(tableStaged.builder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traces, the query the page hands over per view', () => {
|
||||
const staged = stagedQuery(DataSource.TRACES, StringOperators.NOOP);
|
||||
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: count aggregation, group by cleared by the list shaping, filter kept',
|
||||
async (panelType) => {
|
||||
const exportQuery = getTracesQueryByPanelType(staged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
const [queryData] = pushedQuery().builder.queryData;
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.filter).toStrictEqual({ expression: FILTER });
|
||||
},
|
||||
);
|
||||
|
||||
// The list / trace views keep their order in ListView state, and the page
|
||||
// shapes the export without it, so the alert never sees an order by.
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s: order by is not carried, even when the staged query has one',
|
||||
async (panelType) => {
|
||||
expect(staged.builder.queryData[0].orderBy).toStrictEqual(ORDER_BY);
|
||||
const exportQuery = getTracesQueryByPanelType(staged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(pushedQuery().builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
|
||||
'%s: staged query as is',
|
||||
async (panelType) => {
|
||||
const aggStaged = stagedQuery(DataSource.TRACES, StringOperators.COUNT);
|
||||
const exportQuery = getTracesQueryByPanelType(aggStaged, panelType);
|
||||
|
||||
await clickCreateAlert(exportQuery, DataSource.TRACES, panelType);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(aggStaged.builder);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('metrics: the chart query as is', async () => {
|
||||
const query = stagedQuery(DataSource.METRICS, StringOperators.COUNT);
|
||||
|
||||
await clickCreateAlert(query, DataSource.METRICS, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(pushedQuery().builder).toStrictEqual(query.builder);
|
||||
});
|
||||
});
|
||||
144
frontend/src/container/ExplorerActions/__tests__/utils.test.ts
Normal file
144
frontend/src/container/ExplorerActions/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { getCreateAlertLink, getExportPanelType } from '../utils';
|
||||
|
||||
function withFirstQuery(
|
||||
base: Query,
|
||||
overrides: Partial<Query['builder']['queryData'][number]>,
|
||||
): Query {
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], ...overrides }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQuery(link: string): Query {
|
||||
const search = link.split('?')[1];
|
||||
const raw = new URLSearchParams(search).get(QueryParams.compositeQuery);
|
||||
return JSON.parse(raw as string);
|
||||
}
|
||||
|
||||
describe('getExportPanelType', () => {
|
||||
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE, PANEL_TYPES.LIST])(
|
||||
'keeps %s',
|
||||
(panelType) => {
|
||||
expect(getExportPanelType(panelType)).toBe(panelType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([PANEL_TYPES.BAR, PANEL_TYPES.PIE, PANEL_TYPES.TRACE, null])(
|
||||
'folds %s to time series',
|
||||
(panelType) => {
|
||||
expect(getExportPanelType(panelType)).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('getCreateAlertLink', () => {
|
||||
const orderBy = [{ columnName: 'timestamp', order: 'desc' }];
|
||||
|
||||
it('points at the new alert route with the query in the url', () => {
|
||||
const query = initialQueriesMap.traces;
|
||||
const link = getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
expect(link.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
|
||||
expect(decodeQuery(link)).toStrictEqual(query);
|
||||
});
|
||||
|
||||
it('logs list: noop becomes count and order by is dropped', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
}),
|
||||
).builder.queryData;
|
||||
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('logs time series keeps order by', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.COUNT,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
).builder.queryData;
|
||||
|
||||
expect(queryData.orderBy).toStrictEqual(orderBy);
|
||||
});
|
||||
|
||||
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
|
||||
'%s drops order by whatever the source',
|
||||
(panelType) => {
|
||||
const query = withFirstQuery(initialQueriesMap.traces, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
const [queryData] = decodeQuery(getCreateAlertLink({ query, panelType }))
|
||||
.builder.queryData;
|
||||
|
||||
expect(queryData.aggregateOperator).toBe(StringOperators.COUNT);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('converts a noop on any query, not only the first', () => {
|
||||
const first = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query: Query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{ ...first, aggregateOperator: StringOperators.COUNT },
|
||||
{ ...first, queryName: 'B', aggregateOperator: StringOperators.NOOP },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const operators = decodeQuery(
|
||||
getCreateAlertLink({ query, panelType: PANEL_TYPES.TIME_SERIES }),
|
||||
).builder.queryData.map((item) => item.aggregateOperator);
|
||||
|
||||
expect(operators).toStrictEqual([
|
||||
StringOperators.COUNT,
|
||||
StringOperators.COUNT,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mutate the query it is given', () => {
|
||||
const query = withFirstQuery(initialQueriesMap.logs, {
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
orderBy,
|
||||
});
|
||||
const snapshot = JSON.stringify(query);
|
||||
|
||||
getCreateAlertLink({
|
||||
query,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(query)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
46
frontend/src/container/ExplorerActions/utils.ts
Normal file
46
frontend/src/container/ExplorerActions/utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export const EXPLORER_ACTION_EVENTS = {
|
||||
createAlert: 'Explorer: Create alert clicked',
|
||||
addToDashboard: 'Explorer: Add to dashboard clicked',
|
||||
exported: 'Explorer: Add to dashboard successful',
|
||||
} as const;
|
||||
|
||||
export function getExportPanelType(panelType: PANEL_TYPES | null): PANEL_TYPES {
|
||||
return panelType && AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
}
|
||||
|
||||
// Alerts need an aggregation, and list style views carry an order the alert
|
||||
// cannot use.
|
||||
export function getCreateAlertLink({
|
||||
query,
|
||||
panelType,
|
||||
}: {
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES | null;
|
||||
}): string {
|
||||
const isListStyle =
|
||||
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
|
||||
|
||||
const alertQuery = cloneDeep(query);
|
||||
alertQuery.builder.queryData = alertQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
aggregateOperator:
|
||||
item.aggregateOperator === StringOperators.NOOP
|
||||
? StringOperators.COUNT
|
||||
: item.aggregateOperator,
|
||||
orderBy: isListStyle ? [] : item.orderBy,
|
||||
}));
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
|
||||
JSON.stringify(alertQuery),
|
||||
)}`;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0px;
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
.explorer-option-droppable-container {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
width: -webkit-fill-available;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import floppyDiscUrl from '@/assets/Icons/floppy-disc.svg';
|
||||
@@ -36,40 +35,38 @@ export default function SavedViews({
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<
|
||||
SavedviewtypesSavedViewDTO[]
|
||||
>([]);
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<any[]>([]);
|
||||
|
||||
const {
|
||||
data: logsViewsData,
|
||||
isLoading: logsViewsLoading,
|
||||
isError: logsViewsError,
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.logs });
|
||||
} = useGetAllViews(DataSource.LOGS);
|
||||
|
||||
const {
|
||||
data: tracesViewsData,
|
||||
isLoading: tracesViewsLoading,
|
||||
isError: tracesViewsError,
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.traces });
|
||||
} = useGetAllViews(DataSource.TRACES);
|
||||
|
||||
const {
|
||||
data: metricsViewsData,
|
||||
isLoading: metricsViewsLoading,
|
||||
isError: metricsViewsError,
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.metrics });
|
||||
} = useGetAllViews(DataSource.METRICS);
|
||||
|
||||
const logsViews = useMemo(
|
||||
() => [...(logsViewsData?.data || [])],
|
||||
() => [...(logsViewsData?.data.data || [])],
|
||||
[logsViewsData],
|
||||
);
|
||||
|
||||
const tracesViews = useMemo(
|
||||
() => [...(tracesViewsData?.data || [])],
|
||||
() => [...(tracesViewsData?.data.data || [])],
|
||||
[tracesViewsData],
|
||||
);
|
||||
|
||||
const metricsViews = useMemo(
|
||||
() => [...(metricsViewsData?.data || [])],
|
||||
() => [...(metricsViewsData?.data.data || [])],
|
||||
[metricsViewsData],
|
||||
);
|
||||
|
||||
@@ -91,22 +88,39 @@ export default function SavedViews({
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const handleRedirectQuery = (view: SavedviewtypesSavedViewDTO): void => {
|
||||
const handleRedirectQuery = (view: ViewProps): void => {
|
||||
logEvent('Homepage: Saved view clicked', {
|
||||
viewId: view.id,
|
||||
viewName: view.spec.displayName,
|
||||
viewName: view.name,
|
||||
entity: selectedEntity,
|
||||
});
|
||||
|
||||
handleExplorerTabChange(
|
||||
view.spec.panelType,
|
||||
{
|
||||
query: getSavedViewQuery(view),
|
||||
viewName: view.spec.displayName,
|
||||
viewKey: view.id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (selectedEntity === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (selectedEntity === 'traces') {
|
||||
currentViews = tracesViews;
|
||||
} else if (selectedEntity === 'metrics') {
|
||||
currentViews = metricsViews;
|
||||
}
|
||||
|
||||
const currentViewDetails = getViewDetailsUsingViewKey(view.id, currentViews);
|
||||
if (!currentViewDetails) {
|
||||
return;
|
||||
}
|
||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||
|
||||
if (selectedEntity) {
|
||||
handleExplorerTabChange(
|
||||
currentPanelType,
|
||||
{
|
||||
query,
|
||||
viewName: name,
|
||||
viewKey: id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,10 +239,24 @@ export default function SavedViews({
|
||||
/>
|
||||
|
||||
<div className="saved-view-item-name home-data-item-name">
|
||||
{view.spec.displayName}
|
||||
{view.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="saved-view-item-description home-data-item-tag">
|
||||
{view.tags?.map((tag: string) => {
|
||||
if (tag === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="sienna" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -279,7 +307,7 @@ export default function SavedViews({
|
||||
logEvent('Homepage: Saved views switched', {
|
||||
tab,
|
||||
});
|
||||
let currentViews: SavedviewtypesSavedViewDTO[] = [];
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (tab === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (tab === 'traces') {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.licenses-page {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.licenses-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
@@ -29,6 +32,7 @@
|
||||
|
||||
.licenses-page-content {
|
||||
flex: 1;
|
||||
height: calc(100vh - 48px);
|
||||
background: var(--l1-background);
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
@@ -6,7 +6,6 @@ import FieldsSelector from 'components/FieldsSelector';
|
||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { LOGS_REQUIRED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
@@ -14,18 +13,18 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
showFrequencyChart,
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
explorerActions,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
showFrequencyChart: boolean;
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
explorerActions: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -60,48 +59,43 @@ function LogsActionsContainer({
|
||||
<div className="logs-actions-container">
|
||||
<div className="tab-options">
|
||||
<div className="tab-options-left">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="frequency-chart-view-controller">
|
||||
<Typography>Frequency chart</Typography>
|
||||
<Switch
|
||||
value={showFrequencyChart}
|
||||
defaultValue
|
||||
onChange={handleToggleFrequencyChart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-options-right">
|
||||
{selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<>
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
{explorerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
<div className="download-options-container">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
</div>
|
||||
<div className="format-options-container">
|
||||
<LogsFormatOptionsMenu
|
||||
items={formatItems}
|
||||
selectedOptionFormat={options.format}
|
||||
config={config}
|
||||
onOpenColumns={(): void => setIsFieldsSelectorOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{config.fieldsSelector && (
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -140,6 +141,10 @@ function LogsExplorerViewsContainer({
|
||||
[selectedPanelType, requestData],
|
||||
);
|
||||
|
||||
const explorerActions = (
|
||||
<ExplorerActions query={exportDefaultQuery} sourcepage={DataSource.LOGS} />
|
||||
);
|
||||
|
||||
const {
|
||||
data: listChartData,
|
||||
isFetching: isFetchingListChartData,
|
||||
@@ -416,14 +421,14 @@ function LogsExplorerViewsContainer({
|
||||
return (
|
||||
<div className="logs-explorer-views-container">
|
||||
<div className="logs-explorer-views-types">
|
||||
{!showLiveLogs && (
|
||||
{!showLiveLogs && selectedPanelType === PANEL_TYPES.LIST && (
|
||||
<LogsActionsContainer
|
||||
listQuery={listQuery}
|
||||
selectedPanelType={selectedPanelType}
|
||||
showFrequencyChart={showFrequencyChart}
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
explorerActions={explorerActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -474,21 +479,23 @@ function LogsExplorerViewsContainer({
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
<div className="table-view-container">
|
||||
{data && !isError && (
|
||||
<div className="table-view-container-header">
|
||||
<div className="table-view-container-header">
|
||||
{explorerActions}
|
||||
{data && !isError && (
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.metrics}
|
||||
fileName="logs-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
|
||||
@@ -394,6 +394,7 @@ function Explorer(): JSX.Element {
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
showYAxisUnitSelector={showYAxisUnitSelector}
|
||||
isCancelled={isCancelled}
|
||||
exportDefaultQuery={exportDefaultQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -51,6 +52,7 @@ function TimeSeries({
|
||||
showYAxisUnitSelector,
|
||||
metrics,
|
||||
isCancelled = false,
|
||||
exportDefaultQuery,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -272,6 +274,9 @@ function TimeSeries({
|
||||
metricName;
|
||||
|
||||
const currentYAxisUnit = yAxisUnit || metricUnit;
|
||||
const exportQuery = changeLayoutForOneChartPerQuery
|
||||
? queryPayloads[index]
|
||||
: exportDefaultQuery;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -312,6 +317,14 @@ function TimeSeries({
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={
|
||||
<ExplorerActions
|
||||
query={stagedQuery ? exportQuery : null}
|
||||
sourcepage={DataSource.METRICS}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
iconOnly={changeLayoutForOneChartPerQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
@@ -146,9 +147,11 @@ function renderExplorer(): void {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
<TooltipProvider>
|
||||
<ErrorModalProvider>
|
||||
<Explorer />
|
||||
</ErrorModalProvider>
|
||||
</TooltipProvider>
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
|
||||
import TimeSeries from '../TimeSeries';
|
||||
import { TimeSeriesProps } from '../types';
|
||||
@@ -71,6 +72,7 @@ function renderTimeSeries(
|
||||
yAxisUnit="count"
|
||||
setYAxisUnit={mockSetYAxisUnit}
|
||||
showYAxisUnitSelector={false}
|
||||
exportDefaultQuery={initialQueriesMap.metrics}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { MetricsexplorertypesMetricMetadataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
onFetchingStateChange?: (isFetching: boolean) => void;
|
||||
@@ -17,4 +18,5 @@ export interface TimeSeriesProps {
|
||||
setYAxisUnit: (unit: string) => void;
|
||||
showYAxisUnitSelector: boolean;
|
||||
isCancelled?: boolean;
|
||||
exportDefaultQuery: Query;
|
||||
}
|
||||
|
||||
@@ -181,9 +181,7 @@
|
||||
|
||||
.ant-pagination {
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new
|
||||
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
width: calc(100% - 54px);
|
||||
background: var(--l1-background);
|
||||
padding: 16px;
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function makeView(): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
name: 'errors-by-service-abc123',
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'a@b.c',
|
||||
updatedBy: 'a@b.c',
|
||||
spec: {
|
||||
displayName: 'Errors by service',
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
stepInterval: 60,
|
||||
filter: { expression: 'has_error = true' },
|
||||
// v2 reads back fully defaulted envelopes; nulls must not break the mapper
|
||||
groupBy: null,
|
||||
order: null,
|
||||
selectFields: null,
|
||||
functions: null,
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: [{ name: 'service.name' }],
|
||||
display: { color: 'red' },
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
describe('getSavedViewQuery', () => {
|
||||
it('maps the v2 spec through the v5 branch of mapQueryDataFromApi', () => {
|
||||
const query = getSavedViewQuery(makeView());
|
||||
|
||||
expect(query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query.promql).toStrictEqual([]);
|
||||
expect(query.clickhouse_sql).toStrictEqual([]);
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
|
||||
const [queryData] = query.builder.queryData;
|
||||
expect(queryData.queryName).toBe('A');
|
||||
expect(queryData.dataSource).toBe(DataSource.TRACES);
|
||||
expect(queryData.filter).toStrictEqual({ expression: 'has_error = true' });
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps formulas alongside builder queries', () => {
|
||||
const view = makeView();
|
||||
view.spec.queries.push({
|
||||
type: 'builder_formula',
|
||||
spec: { name: 'F1', expression: 'A / 2' },
|
||||
} as SavedviewtypesSavedViewDTO['spec']['queries'][number]);
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
|
||||
});
|
||||
|
||||
it('does not read the panel type into the query', () => {
|
||||
const view = makeView();
|
||||
view.spec.panelType = SavedviewtypesPanelTypeDTO.graph;
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
// panelType travels separately (url param), the Query itself has no such field
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -11,6 +11,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 50vh;
|
||||
min-height: 350px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -66,6 +67,7 @@ function TimeSeriesView({
|
||||
allowExport = false,
|
||||
exportFileName,
|
||||
onYAxisUnitChange,
|
||||
headerActions,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -252,7 +254,7 @@ function TimeSeriesView({
|
||||
);
|
||||
|
||||
const showExport = allowExport && !!data?.rawV5Response;
|
||||
const showHeader = showExport || !!onYAxisUnitChange;
|
||||
const showHeader = showExport || !!onYAxisUnitChange || !!headerActions;
|
||||
|
||||
return (
|
||||
<div className="time-series-view">
|
||||
@@ -265,15 +267,18 @@ function TimeSeriesView({
|
||||
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
|
||||
)}
|
||||
</div>
|
||||
{showExport && data?.rawV5Response && (
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
<div className="time-series-view__header-actions">
|
||||
{headerActions}
|
||||
{showExport && data?.rawV5Response && (
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -344,6 +349,8 @@ interface TimeSeriesViewProps {
|
||||
// Opt-in: render the y-axis unit selector in the header (views without their
|
||||
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
// Rendered in the header ahead of the export menu.
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
TimeSeriesView.defaultProps = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -55,6 +56,7 @@ interface ListViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
@@ -62,6 +64,7 @@ function ListView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
@@ -227,6 +230,7 @@ function ListView({
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
{headerActions}
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
@@ -272,6 +276,7 @@ function ListView({
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -30,10 +31,12 @@ function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -101,14 +104,17 @@ function TableView({
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
{!isError && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
{headerActions}
|
||||
{data && (
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
@@ -125,6 +131,7 @@ function TableView({
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -40,6 +41,7 @@ interface TracesViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
@@ -47,6 +49,7 @@ function TracesView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -155,6 +158,7 @@ function TracesView({
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
{headerActions}
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
@@ -187,6 +191,7 @@ function TracesView({
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
height: calc(100vh - 62px);
|
||||
min-height: 400px;
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.version-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.version-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { deleteView } from 'api/saveView/deleteView';
|
||||
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||
|
||||
export const useDeleteView = (
|
||||
uuid: string,
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> =>
|
||||
useMutation({
|
||||
mutationKey: [uuid],
|
||||
mutationFn: () => deleteView(uuid),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { saveView } from 'api/saveView/saveView';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
|
||||
@@ -14,14 +13,8 @@ export const useSaveView = ({
|
||||
Error,
|
||||
SaveViewProps,
|
||||
SaveViewPayloadProps
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
> =>
|
||||
useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: saveView,
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { updateView } from 'api/saveView/updateView';
|
||||
import {
|
||||
UpdateViewPayloadProps,
|
||||
@@ -17,10 +16,8 @@ export const useUpdateView = ({
|
||||
Error,
|
||||
UpdateViewProps,
|
||||
UpdateViewPayloadProps
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
> =>
|
||||
useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: () =>
|
||||
updateView({
|
||||
@@ -30,8 +27,4 @@ export const useUpdateView = ({
|
||||
sourcePage,
|
||||
viewKey,
|
||||
}),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useSavedViewEnabled(): boolean {
|
||||
const [isEnabled] = useState(
|
||||
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
|
||||
);
|
||||
|
||||
return isEnabled;
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { encode } from 'js-base64';
|
||||
import type { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -32,9 +31,6 @@ import {
|
||||
type ServiceHealth,
|
||||
} from './__story_mockdata__/serviceMap';
|
||||
|
||||
/** The keys are only fetched once the select opens, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const GRAPH = 'Service map · graph';
|
||||
const FILTERS = 'Service map · filters';
|
||||
|
||||
@@ -133,80 +129,3 @@ export const serviceMapMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: serviceMapRoute(values.filters) }),
|
||||
});
|
||||
|
||||
/** Opens the select under a test id; it closes again after every pick. */
|
||||
export const openSelect = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const select = await within(canvasElement).findByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
export const OPEN_DROPDOWN =
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
|
||||
|
||||
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
|
||||
const visibleOption = (title: string): HTMLElement | null =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Picks the option titled `title` in the open dropdown. `userEvent.click`
|
||||
* moves focus off the select on the way, which closes it before the option
|
||||
* takes the click.
|
||||
*/
|
||||
export const pickOption = async (title: string): Promise<void> => {
|
||||
const option = await waitFor(() => {
|
||||
const match = visibleOption(title);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`option "${title}" not found`);
|
||||
}
|
||||
|
||||
return match;
|
||||
}, untilLoaded);
|
||||
|
||||
await fireEvent.click(option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the attribute filter on its next step. Each single-choice pick closes
|
||||
* the dropdown and swaps the select for the next step's, and a click that lands
|
||||
* before the swap opens nothing, so it opens again until `title` shows.
|
||||
*/
|
||||
export const openAttributeFilterOn = (
|
||||
canvasElement: HTMLElement,
|
||||
title: string,
|
||||
): Promise<void> =>
|
||||
waitFor(
|
||||
async () => {
|
||||
if (visibleOption(title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.querySelector(OPEN_DROPDOWN)) {
|
||||
await openSelect(canvasElement, 'resource-attributes-filter');
|
||||
}
|
||||
|
||||
throw new Error(`option "${title}" not shown`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 500 },
|
||||
);
|
||||
|
||||
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
|
||||
export const stageClusterIn = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'IN');
|
||||
await pickOption('IN');
|
||||
await openAttributeFilterOn(canvasElement, 'staging-eu');
|
||||
};
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
OPEN_DROPDOWN,
|
||||
openAttributeFilterOn,
|
||||
openSelect,
|
||||
pickOption,
|
||||
serviceMapMocks,
|
||||
stageClusterIn,
|
||||
} from './ServiceMap.stories.mocks';
|
||||
import { serviceMapMocks } from './ServiceMap.stories.mocks';
|
||||
|
||||
import ServiceMapContainer from '../index';
|
||||
|
||||
@@ -104,49 +97,3 @@ export const FilterAttributes: Story = {
|
||||
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key staged as a chip, the filter open again on how to match it. */
|
||||
export const FilterOperators: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'Not IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and `IN` staged, the filter open on the values the key holds. */
|
||||
export const FilterValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two values ticked before the filter is left, which is what applies it. */
|
||||
export const FilterValuesSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
await pickOption('prod-us-east');
|
||||
await pickOption('prod-eu-west');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-selected'),
|
||||
).toHaveLength(2),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The environment selector open on the environments the calls came from. */
|
||||
export const EnvironmentOptions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSelect(canvasElement, 'resource-environment-filter');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,29 +1,4 @@
|
||||
.alerts-container {
|
||||
// Hands the page height down to the active tab so its content can bound itself
|
||||
// instead of guessing with 100vh. Child combinators only, nested Tabs
|
||||
// (Configuration) must not be caught.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .ant-tabs-content-holder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ant-tabs-tabpane-active {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-level-tab.periscope-tab {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -65,9 +40,5 @@
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,18 +103,6 @@ export const DeleteDowntimeConfirm: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
|
||||
export const NewDowntimeRecurring: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await NewDowntime.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Repeats every' }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Weekly'));
|
||||
await screen.findByText('Duration');
|
||||
},
|
||||
};
|
||||
|
||||
/** A client-side search with no matching downtime schedule. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -6,19 +6,9 @@
|
||||
import { rest } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { countControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
import {
|
||||
@@ -33,9 +23,6 @@ import {
|
||||
type ListErrorsBody,
|
||||
} from './__story_mockdata__/exceptions';
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST = 'Exceptions · list';
|
||||
const FILTERS = 'Exceptions · filters';
|
||||
|
||||
@@ -55,13 +42,6 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
value: 6,
|
||||
max: EXCEPTION_QUICK_FILTER_CAP,
|
||||
}),
|
||||
filterKeys: choiceControl<ResponseState>('Filter keys', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
filterPanel: toggleControl('Quick filters panel', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
@@ -112,7 +92,7 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
respondWith(values.filterKeys, (req) =>
|
||||
response.json((req) =>
|
||||
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
@@ -131,61 +111,3 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
|
||||
},
|
||||
});
|
||||
|
||||
export const openResourceFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'qb-search-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicks the visible row whose label is `text`. The dropdown renders in the
|
||||
* body, a key row carries its type beside the label, and antd keeps a hidden
|
||||
* copy of each label for screen readers that takes no clicks.
|
||||
*
|
||||
* The key list renders twice after the filter opens, since a second key
|
||||
* request empties it until it answers. The click happens in the same task as
|
||||
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
|
||||
* can land on a row already removed and never reach React.
|
||||
*/
|
||||
export const pickSuggestion = async (text: string): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const match = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
).find((option) =>
|
||||
Array.from(option.querySelectorAll('*')).some(
|
||||
(node) => node.children.length === 0 && node.textContent === text,
|
||||
),
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`suggestion "${text}" not found`);
|
||||
}
|
||||
|
||||
// `userEvent.click` moves focus off the search input on the way, which closes
|
||||
// the dropdown before the row takes the click.
|
||||
match.click();
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const commitFilter = async (
|
||||
key: string,
|
||||
operator: string,
|
||||
value: string,
|
||||
): Promise<void> => {
|
||||
await pickSuggestion(key);
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(operator);
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(value);
|
||||
};
|
||||
|
||||
@@ -5,12 +5,7 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
commitFilter,
|
||||
exceptionsMocks,
|
||||
openResourceFilter,
|
||||
pickSuggestion,
|
||||
} from './AllErrors.stories.mocks';
|
||||
import { exceptionsMocks } from './AllErrors.stories.mocks';
|
||||
import AllErrors from '../index';
|
||||
|
||||
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
|
||||
@@ -130,108 +125,3 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The resource filter opened: every key the exceptions can be narrowed by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Suggested Filters', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list grown past its first rows with the Show all shortcut. */
|
||||
export const FilterAllKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Show all filter items', undefined, untilLoaded);
|
||||
await userEvent.keyboard('{Control>}/{/Control}');
|
||||
await screen.findByText('cloud.region', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key: the typed text as a free search, then the keys that match. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const filter = await openResourceFilter(canvasElement);
|
||||
|
||||
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
|
||||
await screen.findByText('service.namespace', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key picked: the operators the exceptions page allows for it. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator picked: the values the key holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await pickSuggestion('=');
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two conditions committed as chips, with the dropdown closed again. */
|
||||
export const FilterChips: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await commitFilter('deployment.environment', '!=', 'staging');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
await within(canvasElement).findByText(
|
||||
'deployment.environment != staging',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A committed chip clicked to change it: its text goes back into the input,
|
||||
* with the dropdown shut until the input is typed into.
|
||||
*/
|
||||
export const FilterEditChip: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'service.name = checkout',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The select remounts whenever its chips change, so it is looked up again.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
|
||||
'combobox',
|
||||
),
|
||||
).toHaveValue('service.name = checkout'),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list while its request is still in flight. */
|
||||
export const FilterKeysLoading: Story = {
|
||||
args: { filterKeys: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector('.query-builder-search .ant-spin'),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
|
||||
// UI belongs in the bounded layout, not in another offset here.
|
||||
bottom: var(--bottom-strip-height, 0px);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import {
|
||||
@@ -34,7 +31,6 @@ import {
|
||||
import {
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
VARIABLE_ATTRIBUTES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from './__story_mockdata__/panelData';
|
||||
@@ -242,12 +238,6 @@ export const dashboardMocks = defineStoryMocks({
|
||||
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
|
||||
),
|
||||
|
||||
// The dynamic variable editor lists the attributes a variable can read.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
// the panels are held in the loading or failed state.
|
||||
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -285,67 +285,6 @@ export const SectionActionsMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
|
||||
export const PanelMoveToSectionSubmenu: Story = {
|
||||
play: async (context) => {
|
||||
await PanelActionsMenu.play?.(context);
|
||||
await userEvent.hover(await screen.findByText('Move to section'));
|
||||
// The submenu lists the sections the panel is not already in.
|
||||
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashboard settings on the Publish tab, where the public link is managed. */
|
||||
export const SettingsPublicDashboard: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Configure' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Publish' }));
|
||||
await screen.findByText('Default time range');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab with its default time range select open. */
|
||||
export const SettingsPublicDashboardSelectOpen: Story = {
|
||||
play: async (context) => {
|
||||
await SettingsPublicDashboard.play?.(context);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('tabpanel')).getByRole('combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Variables tab of dashboard settings with a new variable's form open. */
|
||||
export const SettingsVariablesNew: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Configure' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
|
||||
const add = await within(await screen.findByRole('tabpanel')).findByRole(
|
||||
'button',
|
||||
{ name: 'Add variable' },
|
||||
);
|
||||
|
||||
// The button stays disabled until its permission check resolves.
|
||||
await waitFor(() => expect(add).toBeEnabled(), { timeout: 10000 });
|
||||
await userEvent.click(add);
|
||||
await screen.findByText('Variable Type');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
|
||||
@@ -168,15 +168,6 @@ export const serviceVariableValues = (count: number): string[] =>
|
||||
);
|
||||
|
||||
/** Values the dynamic `namespace` variable resolves from the fields endpoint. */
|
||||
/** Attributes the dynamic variable editor offers a variable to read. */
|
||||
export const VARIABLE_ATTRIBUTES = [
|
||||
'k8s.namespace.name',
|
||||
'k8s.cluster.name',
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'host.name',
|
||||
];
|
||||
|
||||
export const NAMESPACE_VALUES = [
|
||||
'checkout-prod',
|
||||
'payments-prod',
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { rest } from 'msw';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -343,30 +342,3 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens the actions menu of the row at `index`. */
|
||||
export const openRowActions = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
// The icon-only trigger carries no accessible name.
|
||||
const triggers = await within(canvasElement).findAllByTestId(
|
||||
'dashboard-action-icon',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByText('Rename');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
|
||||
await waitFor(
|
||||
async () => {
|
||||
await userEvent.click(screen.getByText(label));
|
||||
await screen.findByRole('dialog', {}, { timeout: 500 });
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardsListMocks,
|
||||
openRowActions,
|
||||
overflowingRows,
|
||||
pickRowAction,
|
||||
} from './DashboardsListPage.stories.mocks';
|
||||
import { BuiltinViewId } from '../types';
|
||||
|
||||
@@ -116,45 +114,6 @@ export const Tooltips: Story = {
|
||||
parameters: { msw: { handlers: [overflowingRows] } },
|
||||
};
|
||||
|
||||
/** The first row's actions menu, open over the list. */
|
||||
export const RowActionsMenu: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
},
|
||||
};
|
||||
|
||||
/** The rename dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const RenameDashboardDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
await screen.findByRole('dialog', { name: 'Rename dashboard' });
|
||||
},
|
||||
};
|
||||
|
||||
/** The tags dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const EditTagsDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
|
||||
},
|
||||
};
|
||||
|
||||
/** The popover that names the current filters as a new saved view. */
|
||||
export const SaveViewPopover: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Save current filters as a view' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('Save as view');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the backend refused: the parse error it returned replaces the
|
||||
* generic failure copy, and there is nothing to retry.
|
||||
|
||||
@@ -164,10 +164,10 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/saved_views',
|
||||
'http://localhost/api/v1/explorer/views',
|
||||
response.json((req) => {
|
||||
const source = req.url.searchParams.get('source') ?? 'logs';
|
||||
const signal = isSavedViewSignal(source) ? source : 'logs';
|
||||
const sourcePage = req.url.searchParams.get('sourcePage') ?? 'logs';
|
||||
const signal = isSavedViewSignal(sourcePage) ? sourcePage : 'logs';
|
||||
|
||||
return savedViewsResponse(
|
||||
values.savedViewSignals.includes(signal) ? values.savedViews : 0,
|
||||
|
||||
@@ -6,21 +6,10 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { checkListStepToPreferenceKeyMap } from 'container/Home/constants';
|
||||
import {
|
||||
type ListSavedViews200,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal as LogsSignal,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
|
||||
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal as TracesSignal,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
type Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
type RuletypesRuleDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
|
||||
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
|
||||
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
|
||||
import type { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
|
||||
import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
@@ -176,53 +165,20 @@ const VIEW_NAMES: Record<SavedViewSignal, string[]> = {
|
||||
export const isSavedViewSignal = (value: string): value is SavedViewSignal =>
|
||||
SAVED_VIEW_SIGNALS.includes(value as SavedViewSignal);
|
||||
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSignal, SavedviewtypesSourceDTO> = {
|
||||
logs: SavedviewtypesSourceDTO.logs,
|
||||
traces: SavedviewtypesSourceDTO.traces,
|
||||
metrics: SavedviewtypesSourceDTO.metrics,
|
||||
};
|
||||
|
||||
const SAVED_VIEW_QUERY: Record<
|
||||
SavedViewSignal,
|
||||
Querybuildertypesv5QueryEnvelopeDTO
|
||||
> = {
|
||||
logs: {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: { name: 'A', signal: LogsSignal.logs },
|
||||
},
|
||||
traces: {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: { name: 'A', signal: TracesSignal.traces },
|
||||
},
|
||||
metrics: {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
spec: { name: 'A', signal: MetricsSignal.metrics },
|
||||
},
|
||||
};
|
||||
|
||||
export const savedViewsResponse = (
|
||||
count: number,
|
||||
signal: SavedViewSignal,
|
||||
): ListSavedViews200 => {
|
||||
const names = VIEW_NAMES[signal];
|
||||
sourcePage: SavedViewSignal,
|
||||
): Record<string, unknown> => {
|
||||
const names = VIEW_NAMES[sourcePage];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: Array.from({ length: Math.min(count, names.length) }, (_, index) => ({
|
||||
id: `storybook-${signal}-view-${index + 1}`,
|
||||
name: `storybook-${signal}-view-${index + 1}`,
|
||||
source: SAVED_VIEW_SOURCE[signal],
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdAt: '2026-08-20T09:00:00Z',
|
||||
createdBy: 'storybook@signoz.io',
|
||||
updatedAt: '2026-08-20T09:00:00Z',
|
||||
updatedBy: 'storybook@signoz.io',
|
||||
spec: {
|
||||
displayName: names[index],
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
queries: [SAVED_VIEW_QUERY[signal]],
|
||||
},
|
||||
...explorerView.data[0],
|
||||
id: `storybook-${sourcePage}-view-${index + 1}`,
|
||||
name: names[index],
|
||||
sourcePage,
|
||||
tags: [sourcePage],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
@@ -46,21 +41,6 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true, drawerTab: VIEWS.EVENTS },
|
||||
};
|
||||
|
||||
/** The selected pod's details drawer, switched to its logs tab. */
|
||||
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
await drawer.findByText('Logs', {}, { timeout: 10000 }),
|
||||
);
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the pod list carries, held open: Collapse Filters beside the
|
||||
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
|
||||
@@ -106,27 +86,3 @@ export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The pod list re-renders the filter when the viewport grows, and each render
|
||||
* reconfigures the editor, which closes its suggestions. Shot at the height
|
||||
* the page opened at.
|
||||
*/
|
||||
const heldViewport = { sbshot: { viewport: { height: 1200 } } };
|
||||
|
||||
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
|
||||
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The pod filter on a namespace: the namespaces the pods run in. */
|
||||
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,12 +69,3 @@ export const GroupActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The first mapping group's edit drawer, opened from its menu. */
|
||||
export const GroupFormDrawer: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await GroupActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await screen.findByText('Edit group');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,24 +54,3 @@ export const ModelCostActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The first pricing rule's drawer, opened from its row menu. */
|
||||
export const ModelCostDrawer: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await ModelCostActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await screen.findByText('Edit model cost');
|
||||
},
|
||||
};
|
||||
|
||||
/** The drawer with its cache mode select open. */
|
||||
export const ModelCostDrawerCacheModeOpen: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await ModelCostDrawer.play?.(context);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Cache mode' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,11 +25,6 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
|
||||
import {
|
||||
logsSavedViewsResponse,
|
||||
@@ -49,8 +44,6 @@ import {
|
||||
logRowsResponse,
|
||||
QUICK_FILTER_MAX,
|
||||
logsQuickFiltersResponse,
|
||||
RECENT_FILTER_MAX,
|
||||
recentFiltersStorage,
|
||||
RELATIVE_TIME,
|
||||
timeRangeState,
|
||||
} from './__story_mockdata__/logs';
|
||||
@@ -165,20 +158,6 @@ export const logsMocks = defineStoryMocks({
|
||||
value: QUICK_FILTER_MAX,
|
||||
max: QUICK_FILTER_MAX,
|
||||
}),
|
||||
filterValues: choiceControl<ResponseState>('Filter values', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/fields/values` answers once a key and an operator are typed in the filter, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
recentFilters: countControl('Recent filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Filters run before in this browser, which the filter offers above its key suggestions.',
|
||||
value: 0,
|
||||
max: RECENT_FILTER_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views', {
|
||||
group: FILTERS,
|
||||
description: 'The views the view picker above the query builder lists.',
|
||||
@@ -238,7 +217,7 @@ export const logsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
respondWith(values.filterValues, (req) =>
|
||||
response.json((req) =>
|
||||
logFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
@@ -285,16 +264,8 @@ export const logsMocks = defineStoryMocks({
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
effect: ({
|
||||
frequencyChart,
|
||||
filtersPanel,
|
||||
format,
|
||||
maxLines,
|
||||
fontSize,
|
||||
recentFilters,
|
||||
}) => {
|
||||
effect: ({ frequencyChart, filtersPanel, format, maxLines, fontSize }) => {
|
||||
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
|
||||
setLocalStorage(...recentFiltersStorage(recentFilters));
|
||||
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, String(filtersPanel));
|
||||
|
||||
// The preferences loader reads localStorage ahead of the URL, so this is
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
blurFilter,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
findSuggestion,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -217,138 +209,3 @@ export const Tooltips: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: every key the logs carry. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'severity_text');
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key, with the keys that still match and the typed part marked. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A string key followed by a space: the operators a string compares with. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
},
|
||||
};
|
||||
|
||||
/** A number key puts the range comparisons first. */
|
||||
export const FilterNumberOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
},
|
||||
};
|
||||
|
||||
/** `NOT` after a key narrows the list to the operators it can negate. */
|
||||
export const FilterNegatedOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the values the key holds, fetched for it. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** Values still being fetched for the key. */
|
||||
export const FilterValuesLoading: Story = {
|
||||
args: { filterValues: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key the backend holds no values for, such as the free-text body. */
|
||||
export const FilterNoValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
},
|
||||
};
|
||||
|
||||
/** The values request failed. */
|
||||
export const FilterValuesError: Story = {
|
||||
args: { filterValues: 'error' },
|
||||
// The values request deliberately fails.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Error loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an `IN` list, after the first value: the rest of the values. */
|
||||
export const FilterInList: Story = {
|
||||
// The editor logs a TypeError while the list is open, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A complete condition: the conjunctions that start the next one. */
|
||||
export const FilterConjunctionSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an opened group: keys, another group and `NOT`. */
|
||||
export const FilterNestedGroup: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
|
||||
},
|
||||
};
|
||||
|
||||
/** A long valid expression mixing operators, left for the next run. */
|
||||
export const FilterComplete: Story = {
|
||||
// The editor logs a TypeError while the `IN` list is typed, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** An incomplete expression after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'service.name = ');
|
||||
},
|
||||
};
|
||||
|
||||
/** Filters run before, offered above the key suggestions. */
|
||||
export const FilterRecentSearches: Story = {
|
||||
args: { recentFilters: 3 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import type { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { STORAGE_VERSION } from 'lib/recentQueries/constants';
|
||||
import type { RecentQueriesStoreShape } from 'lib/recentQueries/types';
|
||||
import { makeId, storageKeyFor } from 'lib/recentQueries/utils';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import type { AppState } from 'store/reducers';
|
||||
@@ -473,34 +470,3 @@ const DASHBOARD_NAMES = [
|
||||
|
||||
export const dashboardsResponse = (): ListDashboardsForUserV2200 =>
|
||||
dashboardsForUserResponse(DASHBOARD_NAMES);
|
||||
|
||||
const RECENT_FILTERS = [
|
||||
"service.name = 'checkout' AND severity_text = 'ERROR'",
|
||||
"k8s.namespace.name = 'observability'",
|
||||
"body CONTAINS 'timeout'",
|
||||
'http.status_code >= 500',
|
||||
"deployment.environment IN ['production', 'staging']",
|
||||
];
|
||||
|
||||
/** The filter lists this many recent entries at most. */
|
||||
export const RECENT_FILTER_MAX = RECENT_FILTERS.length;
|
||||
|
||||
/**
|
||||
* The explorer's recent filters as the store keeps them in localStorage,
|
||||
* newest first, five minutes apart. Dated off `new Date()`, which the story
|
||||
* clock freezes, so the "5 minutes ago" labels hold still.
|
||||
*/
|
||||
export const recentFiltersStorage = (count: number): [string, string] => {
|
||||
const store: RecentQueriesStoreShape = {
|
||||
version: STORAGE_VERSION,
|
||||
entries: RECENT_FILTERS.slice(0, count).map((expression, index) => ({
|
||||
id: makeId('logs', '', expression),
|
||||
signal: 'logs',
|
||||
source: '',
|
||||
filter: { expression },
|
||||
lastUsedAt: new Date().getTime() - (index + 1) * 5 * 60_000,
|
||||
})),
|
||||
};
|
||||
|
||||
return [storageKeyFor('logs', ''), JSON.stringify(store)];
|
||||
};
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -119,18 +114,3 @@ export const MetricDetailsDashboardsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The summary's metric search focused: the attributes metrics can be found by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The metric search on an attribute and an operator: the values it holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,14 +15,10 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
CREATE_OUTCOMES,
|
||||
type CreateOutcome,
|
||||
EXPIRIES,
|
||||
type Expiry,
|
||||
ingestionKeyCreateError,
|
||||
ingestionKeysResponse,
|
||||
KEYS_PER_PAGE,
|
||||
legacyIngestionResponse,
|
||||
@@ -33,11 +29,6 @@ import {
|
||||
const KEYS = 'Ingestion · keys';
|
||||
const LIMITS = 'Ingestion · limits';
|
||||
|
||||
const rejectCreate: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(409), ctx.json(ingestionKeyCreateError()));
|
||||
|
||||
const holdCreate: MockResolver = (_req, res, ctx) => res(ctx.delay('infinite'));
|
||||
|
||||
export const ingestionMocks = defineStoryMocks({
|
||||
controls: {
|
||||
gateway: toggleControl('Gateway', {
|
||||
@@ -59,13 +50,6 @@ export const ingestionMocks = defineStoryMocks({
|
||||
options: EXPIRIES,
|
||||
value: 'none',
|
||||
}),
|
||||
create: choiceControl<CreateOutcome>('Creating a key', {
|
||||
group: KEYS,
|
||||
description:
|
||||
'What the create behind the new key form answers. `hangs` holds the submit button in its loading state.',
|
||||
options: CREATE_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
limits: multiChoiceControl<LimitSignal>('Signals with a limit', {
|
||||
group: LIMITS,
|
||||
description:
|
||||
@@ -101,14 +85,10 @@ export const ingestionMocks = defineStoryMocks({
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/gateway/ingestion_keys',
|
||||
{
|
||||
succeeds: response.json(() => ({
|
||||
status: 'success',
|
||||
data: { id: 'ingestion-key-new', value: 'sk_new' },
|
||||
})),
|
||||
fails: rejectCreate,
|
||||
hangs: holdCreate,
|
||||
}[values.create],
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { id: 'ingestion-key-new', value: 'sk_new' },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.patch(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import dayjs from 'dayjs';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -77,149 +76,16 @@ export const KeyLimits: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
async function openCreateKey(
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<ReturnType<typeof within>> {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'New Ingestion key',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
return within(
|
||||
await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: 'Create new ingestion key' },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function addTag(
|
||||
dialog: ReturnType<typeof within>,
|
||||
tag: string,
|
||||
): Promise<void> {
|
||||
await userEvent.click(await dialog.findByRole('button', { name: /New Tag/ }));
|
||||
await userEvent.keyboard(`${tag}{Enter}`);
|
||||
}
|
||||
|
||||
async function submitCreateKey(
|
||||
dialog: ReturnType<typeof within>,
|
||||
): Promise<void> {
|
||||
await userEvent.type(dialog.getByLabelText('Name'), 'otel-collectors');
|
||||
await userEvent.click(dialog.getByLabelText('Expiration'));
|
||||
await userEvent.click(
|
||||
await screen.findByTitle(dayjs().add(1, 'day').format('YYYY-MM-DD')),
|
||||
);
|
||||
await userEvent.click(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The form a new key is named and dated in. */
|
||||
export const CreateKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openCreateKey(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** A tag typed on the new key and not confirmed yet. */
|
||||
export const CreateKeyAddingTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.click(dialog.getByRole('button', { name: /New Tag/ }));
|
||||
await userEvent.keyboard('team-payments');
|
||||
},
|
||||
};
|
||||
|
||||
/** The new key with its tags confirmed, each one removable. */
|
||||
export const CreateKeyTagsAdded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
for (const tag of ['team-payments', 'env:production']) {
|
||||
await addTag(dialog, tag);
|
||||
await dialog.findByText(tag);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag typed again while the key already has it: Enter keeps the input open
|
||||
* and adds nothing, with no message saying why.
|
||||
*/
|
||||
export const CreateKeyDuplicateTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await addTag(dialog, 'team-payments');
|
||||
await addTag(dialog, 'team-payments');
|
||||
await dialog.findByDisplayValue('team-payments');
|
||||
},
|
||||
};
|
||||
|
||||
/** A tag longer than its pill, cut short with an ellipsis. */
|
||||
export const CreateKeyLongTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
const tag =
|
||||
'team-payments-platform-observability-production-us-east-1-canary-collectors';
|
||||
await addTag(dialog, tag);
|
||||
await dialog.findByText(tag);
|
||||
},
|
||||
};
|
||||
|
||||
/** A name with a space and no expiration, submitted: each field names its rule. */
|
||||
export const CreateKeyInvalid: Story = {
|
||||
// The page logs the rejected validation through `console.error`.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.type(dialog.getByLabelText('Name'), 'otel collectors');
|
||||
await userEvent.click(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
);
|
||||
await dialog.findByText(/should only contain letters/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The expiration calendar, where today and every day before it are disabled. */
|
||||
export const CreateKeyExpirationOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.click(dialog.getByLabelText('Expiration'));
|
||||
await screen.findByTitle(dayjs().format('YYYY-MM-DD'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The new key sent, with the create still in flight. */
|
||||
export const CreateKeySubmitting: Story = {
|
||||
args: { create: 'hangs' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await submitCreateKey(dialog);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
).toHaveAttribute('aria-busy', 'true'),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A create the gateway rejects: the notification names the conflict and the
|
||||
* form keeps what was typed.
|
||||
*/
|
||||
export const CreateKeyFailed: Story = {
|
||||
args: { create: 'fails' },
|
||||
// The deliberate 409 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await submitCreateKey(dialog);
|
||||
await screen.findByText(
|
||||
'An ingestion key with this name already exists.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
await within(canvasElement).findByText(
|
||||
'New Ingestion key',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Create new ingestion key', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
GatewaytypesIngestionKeyDTO,
|
||||
GatewaytypesLimitDTO,
|
||||
GetIngestionKeys200,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { IngestionInfo } from 'types/api/settings/ingestion';
|
||||
|
||||
@@ -22,10 +21,6 @@ export const EXPIRIES = ['none', 'soon', 'expired'] as const;
|
||||
|
||||
export type Expiry = (typeof EXPIRIES)[number];
|
||||
|
||||
export const CREATE_OUTCOMES = ['succeeds', 'fails', 'hangs'] as const;
|
||||
|
||||
export type CreateOutcome = (typeof CREATE_OUTCOMES)[number];
|
||||
|
||||
const KEY_NAMES = [
|
||||
'production-us-east',
|
||||
'production-eu-west',
|
||||
@@ -122,18 +117,6 @@ export const ingestionKeysResponse = (
|
||||
},
|
||||
});
|
||||
|
||||
export const ingestionKeyCreateError = (): RenderErrorResponseDTO => ({
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
type: 'already_exists',
|
||||
message: 'An ingestion key with this name already exists.',
|
||||
url: '',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The pre-gateway endpoint, which answers with one record and no limits: the tab
|
||||
* falls back to it when the gateway feature is off.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
import { RoleType } from 'types/roles';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
@@ -85,18 +84,3 @@ export const roleEditorMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: routeFor(values.mode, values.editor) }),
|
||||
});
|
||||
|
||||
/** Opens the Logs card and returns it. */
|
||||
export const openLogsCard = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const header = await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /^Logs:/ },
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(header);
|
||||
|
||||
return header.parentElement as HTMLElement;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { openLogsCard, roleEditorMocks } from './RoleEditor.stories.mocks';
|
||||
import { roleEditorMocks } from './RoleEditor.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../../Settings';
|
||||
|
||||
@@ -75,27 +75,3 @@ export const Tooltips: Story = {
|
||||
export const TooltipsInJsonEditor: Story = {
|
||||
args: { tooltipsOpen: true, mode: 'edit', editor: 'json' },
|
||||
};
|
||||
|
||||
/** The Logs card's first verb granted over everything instead of nothing. */
|
||||
export const PermissionEditorScope: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [all] = await within(card).findAllByText('All');
|
||||
|
||||
await userEvent.click(all);
|
||||
},
|
||||
};
|
||||
|
||||
/** The selector wizard, opened from a Logs verb scoped to named objects. */
|
||||
export const TelemetrySelectorWizard: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [onlySelected] = await within(card).findAllByText('Only selected');
|
||||
|
||||
await userEvent.click(onlySelected);
|
||||
await userEvent.click(
|
||||
await within(card).findByRole('button', { name: 'Wizard' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Selector Wizard' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.support-page-container {
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.support-page-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -172,31 +172,3 @@ export const TraceOptionsMenu: Story = {
|
||||
await screen.findByText('Preview fields');
|
||||
},
|
||||
};
|
||||
|
||||
/** The span panel moved from the right edge to the bottom through its dock toggle. */
|
||||
export const DockModeSwitched: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// The dock options are icon-only and carry no accessible name.
|
||||
const option = await within(canvasElement).findByTestId(
|
||||
'dock-mode-docked',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(option.querySelector('button') ?? option);
|
||||
},
|
||||
};
|
||||
|
||||
/** The span's percentile badge expanded into the distribution it was ranked in. */
|
||||
export const SpanPercentileOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /^p\d+/ },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/This span duration is/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -29,6 +30,7 @@ function TimeSeriesViewContainer({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -126,6 +128,7 @@ function TimeSeriesViewContainer({
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -137,11 +140,13 @@ interface TimeSeriesViewProps {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
headerActions: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerActions from 'container/ExplorerActions/ExplorerActions';
|
||||
import { getExportPanelType } from 'container/ExplorerActions/utils';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
@@ -194,6 +196,24 @@ function TracesExplorer(): JSX.Element {
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const exportDashboardQuery = useMemo(
|
||||
() =>
|
||||
getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
getExportPanelType(panelType),
|
||||
options,
|
||||
),
|
||||
[exportDefaultQuery, panelType, options],
|
||||
);
|
||||
|
||||
const explorerActions = (
|
||||
<ExplorerActions
|
||||
query={stagedQuery ? exportDefaultQuery : null}
|
||||
dashboardQuery={stagedQuery ? exportDashboardQuery : null}
|
||||
sourcepage={DataSource.TRACES}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
@@ -318,6 +338,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -329,6 +350,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -341,6 +363,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -351,6 +374,7 @@ function TracesExplorer(): JSX.Element {
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
headerActions={explorerActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
.traces-funnel-details {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
// 45px -> height of the tab bar
|
||||
height: calc(100vh - 45px);
|
||||
|
||||
&__steps-config {
|
||||
flex-shrink: 0;
|
||||
width: 600px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
// Positioning context for the absolute .steps-footer.
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Scoped here so the modal usage of FunnelConfiguration on trace details
|
||||
// stays in normal flow.
|
||||
.funnel-configuration {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
&__steps-results {
|
||||
width: 100%;
|
||||
|
||||
@@ -4,17 +4,14 @@
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
&.funnel-details-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// .steps-footer is absolute against the config column, so its 64px is
|
||||
// reserved rather than laid out.
|
||||
margin-bottom: 64px;
|
||||
height: calc(
|
||||
100vh - 170px
|
||||
); // 64px bottom bar + 61px configuration header + 45px page navbar
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
@@ -148,41 +142,3 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: span and resource keys together. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
},
|
||||
};
|
||||
|
||||
/** A context prefix: only the keys that live on the resource. */
|
||||
export const FilterResourceKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the services the spans came from. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** A boolean key offers its two values. */
|
||||
export const FilterBooleanValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
},
|
||||
};
|
||||
|
||||
/** A dangling conjunction after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceSync } from '../sync/usePreferenceSync';
|
||||
import { PreferenceMode } from '../types';
|
||||
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
|
||||
const loaderPreferences = { columns: [{ name: 'from-loader' }] };
|
||||
jest.mock('../loader/usePreferenceLoader', () => ({
|
||||
usePreferenceLoader: jest.fn(() => ({
|
||||
preferences: loaderPreferences,
|
||||
loading: false,
|
||||
error: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../updater/usePreferenceUpdater', () => ({
|
||||
usePreferenceUpdater: jest.fn(() => ({
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseListSavedViews = useListSavedViews as jest.MockedFunction<
|
||||
typeof useListSavedViews
|
||||
>;
|
||||
|
||||
function makeView(
|
||||
id: string,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
spec: Partial<SavedviewtypesSavedViewDTO['spec']>,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
schemaVersion: 'v2',
|
||||
spec: {
|
||||
displayName: id,
|
||||
panelType: 'list',
|
||||
requestType: 'raw',
|
||||
queries: [],
|
||||
...spec,
|
||||
},
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
function mockViews(views: SavedviewtypesSavedViewDTO[]): void {
|
||||
mockedUseListSavedViews.mockReturnValue({
|
||||
data: { status: 'success', data: views },
|
||||
} as unknown as ReturnType<typeof useListSavedViews>);
|
||||
}
|
||||
|
||||
describe('usePreferenceSync in saved view mode', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseListSavedViews.mockReset();
|
||||
});
|
||||
|
||||
it('fetches the list for the data source only in saved view mode', () => {
|
||||
mockViews([]);
|
||||
|
||||
renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.DIRECT,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockedUseListSavedViews).toHaveBeenCalledWith(
|
||||
{ source: 'logs' },
|
||||
{ query: { enabled: false } },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns loader preferences outside saved view mode', () => {
|
||||
mockViews([]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.DIRECT,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences).toBe(loaderPreferences);
|
||||
});
|
||||
|
||||
it('applies selectedFields and display of the active logs view', () => {
|
||||
mockViews([
|
||||
makeView('view-1', SavedviewtypesSourceDTO.logs, {
|
||||
selectedFields: [{ name: 'service.name' }, { name: 'body' }],
|
||||
display: { maxLines: 3, format: 'raw', fontSize: 'large', color: 'red' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: 'view-1',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences?.columns).toStrictEqual(
|
||||
ensureLogsRequiredColumns([{ name: 'service.name' }, { name: 'body' }]),
|
||||
);
|
||||
expect(result.current.preferences?.formatting).toStrictEqual({
|
||||
maxLines: 3,
|
||||
format: 'raw',
|
||||
fontSize: 'large',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to defaults when the view has zero-valued display and no fields', () => {
|
||||
mockViews([
|
||||
makeView('view-1', SavedviewtypesSourceDTO.logs, {
|
||||
selectedFields: undefined,
|
||||
display: { maxLines: 0, format: '', fontSize: '', color: '' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: 'view-1',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences?.columns).toStrictEqual(
|
||||
ensureLogsRequiredColumns(defaultLogsSelectedColumns),
|
||||
);
|
||||
expect(result.current.preferences?.formatting).toStrictEqual({
|
||||
maxLines: 1,
|
||||
format: 'table',
|
||||
fontSize: 'small',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes trace selectedFields through and defaults when absent', () => {
|
||||
mockViews([
|
||||
makeView('with-fields', SavedviewtypesSourceDTO.traces, {
|
||||
selectedFields: [{ name: 'name' }, { name: 'durationNano' }],
|
||||
}),
|
||||
makeView('without-fields', SavedviewtypesSourceDTO.traces, {}),
|
||||
]);
|
||||
|
||||
const withFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
dataSource: DataSource.TRACES,
|
||||
savedViewId: 'with-fields',
|
||||
}),
|
||||
);
|
||||
const withoutFields = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
dataSource: DataSource.TRACES,
|
||||
savedViewId: 'without-fields',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(withFields.result.current.preferences?.columns).toStrictEqual([
|
||||
{ name: 'name' },
|
||||
{ name: 'durationNano' },
|
||||
]);
|
||||
expect(withFields.result.current.preferences?.formatting).toBeUndefined();
|
||||
expect(withoutFields.result.current.preferences?.columns).toBe(
|
||||
defaultTraceSelectedColumns,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults when the saved view id is not in the list', () => {
|
||||
mockViews([makeView('other', SavedviewtypesSourceDTO.logs, {})]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePreferenceSync({
|
||||
mode: PreferenceMode.SAVED_VIEW,
|
||||
dataSource: DataSource.LOGS,
|
||||
savedViewId: 'missing',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.preferences?.columns).toStrictEqual(
|
||||
ensureLogsRequiredColumns(defaultLogsSelectedColumns),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { findSavedView, toSavedViewSource } from 'container/SavedViews/utils';
|
||||
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
@@ -30,16 +28,16 @@ export function usePreferenceSync({
|
||||
updateColumns: (newColumns: TelemetryFieldKey[]) => void;
|
||||
updateFormatting: (newFormatting: FormattingOptions) => void;
|
||||
} {
|
||||
const { data: viewsData } = useListSavedViews(
|
||||
{ source: toSavedViewSource(dataSource) },
|
||||
{ query: { enabled: mode === PreferenceMode.SAVED_VIEW } },
|
||||
const { data: viewsData } = useGetAllViews(
|
||||
dataSource,
|
||||
mode === PreferenceMode.SAVED_VIEW,
|
||||
);
|
||||
|
||||
const [savedViewPreferences, setSavedViewPreferences] =
|
||||
useState<Preferences | null>(null);
|
||||
|
||||
const withColumnNames = (
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
const updateExtraDataSelectColumns = (
|
||||
columns: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] | null => {
|
||||
if (!columns) {
|
||||
return null;
|
||||
@@ -51,28 +49,27 @@ export function usePreferenceSync({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const spec = savedViewId
|
||||
? findSavedView(viewsData?.data, savedViewId)?.spec
|
||||
: undefined;
|
||||
const selectedFields = spec?.selectedFields as
|
||||
| TelemetryFieldKey[]
|
||||
| undefined;
|
||||
const extraData = viewsData?.data?.data?.find(
|
||||
(view) => view.id === savedViewId,
|
||||
)?.extraData;
|
||||
|
||||
const parsedExtraData = JSON.parse(extraData || '{}');
|
||||
let columns: TelemetryFieldKey[] = [];
|
||||
let formatting: FormattingOptions | undefined;
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
columns = ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
updateExtraDataSelectColumns(parsedExtraData?.selectColumns) ||
|
||||
defaultLogsSelectedColumns,
|
||||
);
|
||||
formatting = {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
maxLines: parsedExtraData?.maxLines ?? 1,
|
||||
format: parsedExtraData?.format ?? 'table',
|
||||
fontSize: parsedExtraData?.fontSize ?? 'small',
|
||||
version: parsedExtraData?.version ?? 1,
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = selectedFields || defaultTraceSelectedColumns;
|
||||
columns = parsedExtraData?.selectColumns || defaultTracesSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
|
||||
15
tests/fixtures/alerts.py
vendored
15
tests/fixtures/alerts.py
vendored
@@ -108,23 +108,14 @@ def delete_all_rules(signoz: types.SigNoz, token: str) -> None:
|
||||
def seed_alert_rules(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
) -> Callable[[str, list[dict]], None]:
|
||||
) -> Callable[[dict, list[dict]], None]:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# create_notification_channel rather than create_webhook_notification_channel:
|
||||
# only the former deletes on teardown, and callers reuse one channel name
|
||||
# across tests, so a leaked channel fails the next create as a duplicate.
|
||||
def _seed_alert_rules(channel_name: str, rules: list[dict]) -> None:
|
||||
def _seed_alert_rules(channel_config: dict, rules: list[dict]) -> None:
|
||||
delete_all_rules(signoz, admin_token)
|
||||
create_notification_channel(
|
||||
{
|
||||
"name": channel_name,
|
||||
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get(f"/alert/{channel_name}"), "send_resolved": False}],
|
||||
}
|
||||
)
|
||||
create_notification_channel(channel_config)
|
||||
for rule in rules:
|
||||
create_alert_rule(rule)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user