mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-30 23:30:40 +01:00
Compare commits
1 Commits
test/inter
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5ddeb04d |
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
|
||||
|
||||
@@ -70,49 +70,3 @@
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Storybook has no microphone. Chromium's recognizer asks for one and for
|
||||
// Google's speech service, and the `not-allowed` or `network` it reports makes
|
||||
// the assistant hide its mic for the rest of the tab. This one starts
|
||||
// listening at once and hears only what a story passes to `hear`.
|
||||
(() => {
|
||||
class StorySpeechRecognition extends EventTarget {
|
||||
static listening = null;
|
||||
lang = 'en-US';
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
onstart = null;
|
||||
onresult = null;
|
||||
onerror = null;
|
||||
onend = null;
|
||||
|
||||
start() {
|
||||
StorySpeechRecognition.listening = this;
|
||||
setTimeout(() => this.onstart?.());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (StorySpeechRecognition.listening === this) {
|
||||
StorySpeechRecognition.listening = null;
|
||||
}
|
||||
setTimeout(() => this.onend?.());
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** An interim result: the words so far, still listening. */
|
||||
hear(transcript) {
|
||||
const result = Object.assign([{ transcript, confidence: 1 }], {
|
||||
isFinal: false,
|
||||
});
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}
|
||||
}
|
||||
|
||||
window.SpeechRecognition = StorySpeechRecognition;
|
||||
window.webkitSpeechRecognition = StorySpeechRecognition;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -122,21 +122,7 @@ const preview: Preview = {
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
[
|
||||
'List',
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
'Edit Variable',
|
||||
'Authz',
|
||||
],
|
||||
'Panel Editor',
|
||||
['Overview', 'Chart Options', 'Thresholds and Links'],
|
||||
'Public',
|
||||
],
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
|
||||
@@ -23,13 +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+ /,
|
||||
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
|
||||
// already reports with its URL and the time its request left.
|
||||
/^Failed to load resource: the server responded with a status of /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
@@ -93,9 +86,7 @@ const config: TestRunnerConfig = {
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push({
|
||||
// The browser's clock, when the request left: a busy runner receives
|
||||
// the outgoing story's responses after the next story has started.
|
||||
at: response.request().timing().startTime,
|
||||
at: Date.now(),
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ const mockPaths = {
|
||||
const mockTzDate = jest.fn(
|
||||
(date: Date, _timezone: string) => new Date(date.getTime()),
|
||||
);
|
||||
const mockOrient = jest.fn();
|
||||
|
||||
// Mock uPlot constructor - this needs to be a proper constructor function
|
||||
function MockUPlot(
|
||||
@@ -61,6 +62,9 @@ function MockUPlot(
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
MockUPlot.orient = mockOrient;
|
||||
// Pinned so canvas-space maths in path builders is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Surface matches the shared Tooltip: same tokens, same radius, no shadow.
|
||||
// Padding lives on the sections so a footer can reach the container edges.
|
||||
.container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.marker {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
// The group values name the point; the channels are what it says.
|
||||
.rowMuted {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
111
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
111
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* One point, its channels, then the group values that name it. Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, whose list is one row per
|
||||
* series at a shared x; a scatter point has no such neighbours.
|
||||
*/
|
||||
export default function ScatterTooltip({
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
decimalPrecision,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: ScatterTooltipProps): JSX.Element | null {
|
||||
const point = useMemo(
|
||||
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
|
||||
[uPlotInstance, seriesIndex, dataIndexes],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => (point ? buildChannelRows(point, channels, decimalPrecision) : []),
|
||||
[point, channels, decimalPrecision],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
point
|
||||
? (resolvePointLabels?.(point.seriesIndex, point.dataIndex) ?? [])
|
||||
: [],
|
||||
[point, resolvePointLabels],
|
||||
);
|
||||
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="scatter-tooltip"
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span className={Styles.marker} style={{ backgroundColor: point.color }} />
|
||||
<span
|
||||
className={Styles.title}
|
||||
title={point.label}
|
||||
data-testid="scatter-tooltip-title"
|
||||
>
|
||||
{point.label}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={Styles.status} data-testid="scatter-tooltip-status">
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="scatter-tooltip-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div className={Styles.rows}>
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.key}
|
||||
className={cx(Styles.row, Styles.rowMuted)}
|
||||
data-testid="scatter-tooltip-label"
|
||||
>
|
||||
<span className={Styles.rowLabel} title={label.key}>
|
||||
{label.key}
|
||||
</span>
|
||||
<span className={Styles.rowValue} title={label.value}>
|
||||
{label.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
buildChannelRows,
|
||||
resolveHoveredPoint,
|
||||
ScatterHoveredPoint,
|
||||
} from '../scatterTooltipContent';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
|
||||
`${value} ${unit ?? ''}`.trim(),
|
||||
),
|
||||
}));
|
||||
|
||||
const plot = {
|
||||
data: [
|
||||
null,
|
||||
[
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
[5, null],
|
||||
],
|
||||
[[30], [300]],
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: 'cart', stroke: '#ff0000' },
|
||||
{ label: 'checkout', stroke: (): string => '#00ff00' },
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
|
||||
describe('resolveHoveredPoint', () => {
|
||||
it('reads the focused series at its own data index', () => {
|
||||
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 1,
|
||||
label: 'cart',
|
||||
color: '#ff0000',
|
||||
x: 20,
|
||||
y: 200,
|
||||
size: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the size column when present and resolves function strokes', () => {
|
||||
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
|
||||
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
|
||||
label: 'checkout',
|
||||
color: '#00ff00',
|
||||
size: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null without a focused series or an index for it', () => {
|
||||
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
|
||||
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
|
||||
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChannelRows', () => {
|
||||
const point: ScatterHoveredPoint = {
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
label: 'cart',
|
||||
color: '#f00',
|
||||
x: 12,
|
||||
y: 340,
|
||||
size: 7,
|
||||
};
|
||||
|
||||
it('formats x and y with their own units', () => {
|
||||
const rows = buildChannelRows(point, {
|
||||
x: { label: 'Throughput', unit: 'reqps' },
|
||||
y: { label: 'p99', unit: 'ms' },
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{ label: 'Throughput', value: '12 reqps' },
|
||||
{ label: 'p99', value: '340 ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds the size row only when the channel is mapped and the point has one', () => {
|
||||
const channels = {
|
||||
x: { label: 'x' },
|
||||
y: { label: 'y' },
|
||||
size: { label: 'Errors' },
|
||||
};
|
||||
|
||||
expect(buildChannelRows(point, channels)).toHaveLength(3);
|
||||
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type {
|
||||
ScatterChannel,
|
||||
ScatterChannels,
|
||||
ScatterSeriesData,
|
||||
} from '../../plugins/ScatterPlugin/types';
|
||||
import { resolveSeriesColor } from './utils';
|
||||
|
||||
export interface ScatterHoveredPoint {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface ScatterTooltipRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** The point the cursor resolved to: the focused series' own index into its columns. */
|
||||
export function resolveHoveredPoint(
|
||||
u: uPlot,
|
||||
seriesIndex: number | null,
|
||||
dataIndexes: Array<number | null>,
|
||||
): ScatterHoveredPoint | null {
|
||||
if (seriesIndex == null || seriesIndex < 1) {
|
||||
return null;
|
||||
}
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const series = u.series[seriesIndex];
|
||||
const columns = u.data[seriesIndex] as unknown as
|
||||
| ScatterSeriesData
|
||||
| undefined;
|
||||
if (dataIndex == null || !series || !columns) {
|
||||
return null;
|
||||
}
|
||||
const x = columns[0][dataIndex];
|
||||
const y = columns[1][dataIndex];
|
||||
if (x == null || y == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
seriesIndex,
|
||||
dataIndex,
|
||||
label: String(series.label ?? ''),
|
||||
color: resolveSeriesColor(series.stroke, u, seriesIndex),
|
||||
x,
|
||||
y,
|
||||
size: columns[2]?.[dataIndex] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatChannel(
|
||||
value: number,
|
||||
channel: ScatterChannel,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): string {
|
||||
return getToolTipValue(value, channel.unit, decimalPrecision);
|
||||
}
|
||||
|
||||
export function buildChannelRows(
|
||||
point: ScatterHoveredPoint,
|
||||
channels: ScatterChannels,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): ScatterTooltipRow[] {
|
||||
const rows: ScatterTooltipRow[] = [
|
||||
{
|
||||
label: channels.x.label,
|
||||
value: formatChannel(point.x, channels.x, decimalPrecision),
|
||||
},
|
||||
{
|
||||
label: channels.y.label,
|
||||
value: formatChannel(point.y, channels.y, decimalPrecision),
|
||||
},
|
||||
];
|
||||
if (channels.size && point.size != null) {
|
||||
rows.push({
|
||||
label: channels.size.label,
|
||||
value: formatChannel(point.size, channels.size, decimalPrecision),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from '../plugins/ScatterPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -103,6 +107,17 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {}
|
||||
|
||||
/** Not part of `TooltipProps`: it describes one point's channels, not a series list. */
|
||||
export interface ScatterTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
channels: ScatterChannels;
|
||||
/** The group values behind a point, e.g. `service.name` → `cart`. */
|
||||
resolvePointLabels?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -58,32 +58,49 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build values formatter for X-axis (time)
|
||||
* Build values formatter for X-axis: time, or a value axis when a unit or
|
||||
* precision is given (scatter). Neither leaves uPlot's numeric default.
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { isTimeAxis } = this.props;
|
||||
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
|
||||
|
||||
if (isTimeAxis) {
|
||||
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
|
||||
}
|
||||
|
||||
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
|
||||
return this.buildValueAxisFormatter();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build values formatter for Y-axis (values with units)
|
||||
* Build values formatter for a value axis (values with units). A split outside
|
||||
* the scale's range gets no label: uPlot's arcsinh splits always include
|
||||
* ±threshold, and it would draw that label past the plot's edge.
|
||||
*/
|
||||
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision } = this.props;
|
||||
private buildValueAxisFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
|
||||
|
||||
return (_, t): string[] =>
|
||||
t.map((v) => {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) {
|
||||
return (u, t): string[] => {
|
||||
const scale = u?.scales?.[scaleKey];
|
||||
const min = scale?.min ?? -Infinity;
|
||||
const max = scale?.max ?? Infinity;
|
||||
return t.map((v) => {
|
||||
if (
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
Number.isNaN(v) ||
|
||||
v < min ||
|
||||
v > max
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
|
||||
return `${value}`;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +118,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
return scaleKey === 'x'
|
||||
? this.buildXAxisValuesFormatter()
|
||||
: scaleKey === 'y'
|
||||
? this.buildYAxisValuesFormatter()
|
||||
? this.buildValueAxisFormatter()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ConfigBuilder,
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
@@ -65,6 +66,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private mode: PlotMode = PlotMode.Aligned;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -160,6 +163,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
/** Faceted series carry their own x column each; see `SeriesProps.facets`. */
|
||||
setMode(mode: PlotMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
getMode(): PlotMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -512,6 +524,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
if (this.mode === PlotMode.Faceted) {
|
||||
config.mode = this.mode as number as uPlot.Mode;
|
||||
}
|
||||
|
||||
config.hooks = this.hooks;
|
||||
config.select = this.select;
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold: this.props.asinhThreshold,
|
||||
});
|
||||
|
||||
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =
|
||||
|
||||
@@ -87,6 +87,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = finalFillColor;
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (this.props.drawStyle === DrawStyle.Scatter) {
|
||||
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
const resolvedOpacity = resolveFillOpacity(fillOpacity);
|
||||
if (fillMode === FillMode.Solid) {
|
||||
@@ -122,7 +124,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
return { paths: pathBuilder };
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
// Scatter without a `pathBuilder` has nothing to draw its discs with.
|
||||
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
|
||||
return { paths: (): null => null };
|
||||
}
|
||||
|
||||
@@ -194,6 +197,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
return true;
|
||||
}
|
||||
// The discs are the series path; uPlot's own points would double-draw them.
|
||||
if (drawStyle === DrawStyle.Scatter) {
|
||||
return false;
|
||||
}
|
||||
return !!showPoints;
|
||||
}
|
||||
|
||||
@@ -218,7 +225,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -246,6 +253,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(facets && { facets }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,3 +376,51 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder value x axis', () => {
|
||||
it('formats a non-time x axis with its unit', () => {
|
||||
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
|
||||
).getConfig();
|
||||
|
||||
const values = (config.values as uPlot.Axis.DynamicValues)(
|
||||
{} as uPlot,
|
||||
[1200],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['1.2K req/s']);
|
||||
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
|
||||
});
|
||||
|
||||
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder out-of-range splits', () => {
|
||||
it('leaves a split the scale cannot place unlabelled', () => {
|
||||
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
|
||||
).getConfig();
|
||||
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
|
||||
|
||||
const values = (config.values as uPlot.Axis.DynamicValues)(
|
||||
u,
|
||||
[-10, 0, 500, 5000],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -651,3 +656,15 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotConfigBuilder plot mode', () => {
|
||||
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
|
||||
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
|
||||
expect(aligned.getConfig().mode).toBeUndefined();
|
||||
|
||||
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
|
||||
faceted.setMode(PlotMode.Faceted);
|
||||
expect(faceted.getMode()).toBe(PlotMode.Faceted);
|
||||
expect(faceted.getConfig().mode).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,3 +399,39 @@ describe('UPlotSeriesBuilder', () => {
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotSeriesBuilder scatter', () => {
|
||||
it('draws through the given path builder and hides uPlot points', () => {
|
||||
const pathBuilder = jest.fn();
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Scatter,
|
||||
pathBuilder,
|
||||
facets: [{ scale: 'x' }, { scale: 'y' }],
|
||||
lineColor: '#ff0000',
|
||||
fillOpacity: 0.5,
|
||||
lineWidth: 1,
|
||||
pointSize: 8,
|
||||
}),
|
||||
).getConfig();
|
||||
|
||||
expect(config.paths).toBe(pathBuilder);
|
||||
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
|
||||
expect(config.points?.show).toBe(false);
|
||||
expect(config.points?.size).toBe(8);
|
||||
expect(config.stroke).toBe('#ff0000');
|
||||
expect(config.width).toBe(1);
|
||||
expect(config.fill).toBe('#ff000080');
|
||||
});
|
||||
|
||||
it('draws nothing without a path builder', () => {
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Scatter }),
|
||||
).getConfig();
|
||||
|
||||
expect(
|
||||
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
|
||||
).toBeNull();
|
||||
expect(config.facets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,8 @@ export interface AxisProps {
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
|
||||
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
|
||||
* y axis, the only value axis until scatter; a non-time x axis reads it too. */
|
||||
yAxisUnit?: string;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
@@ -107,6 +108,15 @@ export interface AxisProps {
|
||||
export enum DistributionType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
/** arcsinh: linear within ±`asinhThreshold`, logarithmic beyond. Takes zero and
|
||||
* negatives, which a plain log cannot place. */
|
||||
SymmetricLog = 'symlog',
|
||||
}
|
||||
|
||||
/** uPlot's data layout: one shared x per chart, or per-series x/y columns. */
|
||||
export enum PlotMode {
|
||||
Aligned = 1,
|
||||
Faceted = 2,
|
||||
}
|
||||
|
||||
export interface ScaleProps {
|
||||
@@ -123,6 +133,8 @@ export interface ScaleProps {
|
||||
auto?: boolean;
|
||||
logBase?: uPlot.Scale.LogBase;
|
||||
distribution?: DistributionType;
|
||||
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
export enum DisconnectedValuesMode {
|
||||
@@ -144,6 +156,8 @@ export enum DrawStyle {
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
/** Faceted (mode 2) discs at per-series x/y, drawn by the caller's `pathBuilder`. */
|
||||
Scatter = 'scatter',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -227,6 +241,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
/** Mode 2 only: the scales the series' own x and y columns are read against. */
|
||||
facets?: Series.Facet[];
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { Quadtree } from '../../../utils/quadtree';
|
||||
import {
|
||||
resolveHit,
|
||||
resolvePointDiameter,
|
||||
resolveSizeDomain,
|
||||
} from '../geometry';
|
||||
import { ScatterHit, ScatterPointSize } from '../types';
|
||||
|
||||
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
|
||||
|
||||
const asData = (columns: unknown[]): uPlot.AlignedData =>
|
||||
columns as unknown as uPlot.AlignedData;
|
||||
|
||||
describe('resolveSizeDomain', () => {
|
||||
it('spans the size columns of every series, skipping nulls', () => {
|
||||
const data = asData([
|
||||
null,
|
||||
[
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
[10, null],
|
||||
],
|
||||
[[3], [3], [40]],
|
||||
]);
|
||||
|
||||
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
|
||||
});
|
||||
|
||||
it('is null when no series carries sizes', () => {
|
||||
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
|
||||
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePointDiameter', () => {
|
||||
it('uses the fixed diameter without a size or a domain', () => {
|
||||
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
|
||||
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
|
||||
});
|
||||
|
||||
it('maps the domain ends to min and max', () => {
|
||||
const domain = { min: 0, max: 100 };
|
||||
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
|
||||
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
|
||||
});
|
||||
|
||||
it('scales by area, not diameter', () => {
|
||||
const midArea = (4 ** 2 + 20 ** 2) / 2;
|
||||
expect(
|
||||
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
|
||||
).toBeCloseTo(Math.sqrt(midArea));
|
||||
});
|
||||
|
||||
it('clamps values outside the domain', () => {
|
||||
const domain = { min: 10, max: 20 };
|
||||
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
|
||||
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
|
||||
});
|
||||
|
||||
it('uses the midpoint when every size is the same', () => {
|
||||
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHit', () => {
|
||||
const hit = (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
d: number,
|
||||
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
|
||||
|
||||
it('returns the disc under the cursor', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
tree.add(hit(2, 3, 50, 50, 6));
|
||||
|
||||
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
});
|
||||
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
|
||||
seriesIndex: 2,
|
||||
dataIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null when the cursor is off every disc', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
|
||||
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('tolerance widens each disc', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
|
||||
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
|
||||
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the disc whose centre is nearest when they overlap', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 10));
|
||||
tree.add(hit(1, 1, 14, 10, 10));
|
||||
|
||||
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
|
||||
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PlotMode } from '../../../config/types';
|
||||
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from '../scatterPlugin';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}));
|
||||
|
||||
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
|
||||
class FakePath2D {
|
||||
moveTo = jest.fn();
|
||||
arc = jest.fn();
|
||||
}
|
||||
|
||||
type OrientCallback = Parameters<typeof uPlot.orient>[2];
|
||||
|
||||
interface FakePlotArgs {
|
||||
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
|
||||
cursor?: { left: number; top: number };
|
||||
scaleX?: { min: number; max: number };
|
||||
scaleY?: { min: number; max: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
|
||||
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
|
||||
*/
|
||||
function createFakePlot({
|
||||
series,
|
||||
cursor = { left: -1, top: -1 },
|
||||
scaleX = { min: 0, max: 100 },
|
||||
scaleY = { min: 0, max: 100 },
|
||||
}: FakePlotArgs): uPlot {
|
||||
const data = [
|
||||
null,
|
||||
...series.map((entry) =>
|
||||
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
|
||||
),
|
||||
];
|
||||
return {
|
||||
data,
|
||||
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
|
||||
bbox: { left: 0, top: 0, width: 100, height: 100 },
|
||||
cursor,
|
||||
scales: { x: scaleX, y: scaleY },
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
|
||||
function orientWithIdentityScales(
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
cb: OrientCallback,
|
||||
): void {
|
||||
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
|
||||
if (!columns) {
|
||||
return;
|
||||
}
|
||||
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
|
||||
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
|
||||
const valToPosX = (value: number): number => value;
|
||||
const valToPosY = (value: number): number => 100 - value;
|
||||
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
|
||||
const arc = (path: FakePath2D, ...args: number[]): void => {
|
||||
path.arc(...args);
|
||||
};
|
||||
cb(
|
||||
u.series[seriesIdx],
|
||||
columns[0],
|
||||
columns[1],
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX as unknown as uPlot.ValToPos,
|
||||
valToPosY as unknown as uPlot.ValToPos,
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100,
|
||||
jest.fn() as never,
|
||||
jest.fn() as never,
|
||||
jest.fn() as never,
|
||||
arc as never,
|
||||
jest.fn() as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('createScatterPlugin', () => {
|
||||
beforeAll(() => {
|
||||
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(uPlot.orient as jest.Mock).mockImplementation(orientWithIdentityScales);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(uPlot.orient as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
function drawAll(
|
||||
u: uPlot,
|
||||
plugin: ReturnType<typeof createScatterPlugin>,
|
||||
): void {
|
||||
plugin.hooks.drawClear(u);
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
|
||||
const columns = (u.data as unknown as number[][][])[seriesIdx];
|
||||
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the cursor scan the way uPlot does: every data series, in order. */
|
||||
function scan(
|
||||
u: uPlot,
|
||||
plugin: ReturnType<typeof createScatterPlugin>,
|
||||
): Array<number | null> {
|
||||
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
|
||||
const indexes: Array<number | null> = [null];
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
|
||||
indexes.push(dataIdx(u, seriesIdx, 0, 0));
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
it('returns one path that strokes and fills the same discs', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
|
||||
plugin.hooks.drawClear(u);
|
||||
|
||||
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
|
||||
|
||||
expect(paths.stroke).toBeInstanceOf(FakePath2D);
|
||||
expect(paths.fill).toBe(paths.stroke);
|
||||
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resolves the hovered point to its own series and index', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 6, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [
|
||||
{ xs: [10, 50], ys: [10, 50] },
|
||||
{ xs: [80], ys: [80] },
|
||||
],
|
||||
// Over the second series' only point: x 80, y drawn at 100 − 80.
|
||||
cursor: { left: 80, top: 20 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
|
||||
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
|
||||
});
|
||||
|
||||
it('returns null for every series when the cursor is off the plot or off any disc', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: -1, top: -1 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null]);
|
||||
|
||||
(u.cursor as { left: number; top: number }).left = 50;
|
||||
(u.cursor as { left: number; top: number }).top = 50;
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
it('skips points outside the visible scale range', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10, 500], ys: [10, 10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
|
||||
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
|
||||
expect(scan(u, plugin)).toStrictEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('sizes the hover marker from the hit disc, in CSS pixels', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 8, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
scan(u, plugin);
|
||||
|
||||
const bbox = plugin.cursor.points?.bbox;
|
||||
expect(bbox?.(u, 1)).toStrictEqual({ left: 6, top: 86, width: 8, height: 8 });
|
||||
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('drawClear drops cached paths on data series only', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
|
||||
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
|
||||
xSeries._paths = 'x';
|
||||
dataSeries._paths = 'cached';
|
||||
|
||||
plugin.hooks.drawClear(u);
|
||||
|
||||
expect(xSeries._paths).toBe('x');
|
||||
expect(dataSeries._paths).toBeNull();
|
||||
});
|
||||
|
||||
it('focus distance is zero, so the hit series wins focus', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyScatterPlugin', () => {
|
||||
it('switches the builder to faceted mode and disables drag selection', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'scatter' });
|
||||
const plugin = createScatterPlugin();
|
||||
|
||||
applyScatterPlugin(builder, plugin);
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(builder.getMode()).toBe(PlotMode.Faceted);
|
||||
expect(config.mode).toBe(2);
|
||||
expect(config.cursor?.drag).toMatchObject({
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: false,
|
||||
});
|
||||
expect(config.hooks?.drawClear).toHaveLength(1);
|
||||
expect(config.hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('facets read x and y against the shared scales', () => {
|
||||
expect(SCATTER_FACETS).toStrictEqual([
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
96
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { Quadtree } from '../../utils/quadtree';
|
||||
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
|
||||
|
||||
export interface SizeDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent of the size column across every series, so equal values draw equal
|
||||
* discs whichever group they belong to. `null` when nothing carries a size.
|
||||
*/
|
||||
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
|
||||
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
|
||||
if (!sizes) {
|
||||
continue;
|
||||
}
|
||||
for (const size of sizes) {
|
||||
if (size == null || !Number.isFinite(size)) {
|
||||
continue;
|
||||
}
|
||||
min = Math.min(min, size);
|
||||
max = Math.max(max, size);
|
||||
}
|
||||
}
|
||||
return min <= max ? { min, max } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disc diameter for a size value. Area, not diameter, follows the value: a
|
||||
* point worth twice as much should look twice as big.
|
||||
*/
|
||||
export function resolvePointDiameter(
|
||||
size: number | null | undefined,
|
||||
domain: SizeDomain | null,
|
||||
pointSize: ScatterPointSize,
|
||||
): number {
|
||||
if (size == null || domain == null || !Number.isFinite(size)) {
|
||||
return pointSize.fixed;
|
||||
}
|
||||
if (domain.max === domain.min) {
|
||||
return (pointSize.min + pointSize.max) / 2;
|
||||
}
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
|
||||
);
|
||||
const minArea = pointSize.min ** 2;
|
||||
const maxArea = pointSize.max ** 2;
|
||||
return Math.sqrt(minArea + (maxArea - minArea) * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest disc under the cursor, or `null`. Overlapping discs resolve to the one
|
||||
* whose centre is closest; `tolerance` widens every disc so thin points stay
|
||||
* hoverable.
|
||||
*/
|
||||
export function resolveHit(
|
||||
tree: Quadtree<ScatterHit>,
|
||||
cx: number,
|
||||
cy: number,
|
||||
tolerance: number,
|
||||
): ScatterHit | null {
|
||||
let best: ScatterHit | null = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
tree.get(
|
||||
cx - tolerance,
|
||||
cy - tolerance,
|
||||
tolerance * 2,
|
||||
tolerance * 2,
|
||||
(hit) => {
|
||||
const left = hit.x - tolerance;
|
||||
const top = hit.y - tolerance;
|
||||
const right = hit.x + hit.w + tolerance;
|
||||
const bottom = hit.y + hit.h + tolerance;
|
||||
if (cx < left || cx > right || cy < top || cy > bottom) {
|
||||
return;
|
||||
}
|
||||
const dx = cx - (hit.x + hit.w / 2);
|
||||
const dy = cy - (hit.y + hit.h / 2);
|
||||
const distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance) {
|
||||
best = hit;
|
||||
bestDistance = distance;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return best;
|
||||
}
|
||||
206
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
206
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
|
||||
import { PlotMode } from '../../config/types';
|
||||
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import { Quadtree } from '../../utils/quadtree';
|
||||
import {
|
||||
resolveHit,
|
||||
resolvePointDiameter,
|
||||
resolveSizeDomain,
|
||||
SizeDomain,
|
||||
} from './geometry';
|
||||
import {
|
||||
DEFAULT_HOVER_TOLERANCE_PX,
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterHit,
|
||||
ScatterPluginOptions,
|
||||
ScatterSeriesData,
|
||||
} from './types';
|
||||
|
||||
/** Every scatter series reads its own x and y columns against the shared scales. */
|
||||
export const SCATTER_FACETS: Series.Facet[] = [
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
];
|
||||
|
||||
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
|
||||
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
|
||||
/** uPlot caches built paths on the series; the field is internal to it. */
|
||||
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
|
||||
|
||||
export interface ScatterPlugin {
|
||||
/** Draws every point of a series as one path and indexes the discs for hover. */
|
||||
pathBuilder: Series.PathBuilder;
|
||||
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
|
||||
cursor: uPlot.Cursor;
|
||||
hooks: {
|
||||
drawClear: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
};
|
||||
getHit: () => ScatterHit | null;
|
||||
}
|
||||
|
||||
export function createScatterPlugin({
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
|
||||
}: ScatterPluginOptions = {}): ScatterPlugin {
|
||||
let tree: Quadtree<ScatterHit> | null = null;
|
||||
let hit: ScatterHit | null = null;
|
||||
|
||||
// The domain spans every series, so it is resolved once per dataset rather than
|
||||
// once per series path.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedDomain: SizeDomain | null = null;
|
||||
|
||||
function getSizeDomain(u: uPlot): SizeDomain | null {
|
||||
if (cachedData !== u.data) {
|
||||
cachedDomain = resolveSizeDomain(u.data);
|
||||
cachedData = u.data;
|
||||
}
|
||||
return cachedDomain;
|
||||
}
|
||||
|
||||
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
|
||||
const path = new Path2D();
|
||||
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
|
||||
const domain = getSizeDomain(u);
|
||||
const { pxRatio } = uPlot;
|
||||
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
_series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
_moveTo,
|
||||
_lineTo,
|
||||
_rect,
|
||||
arc,
|
||||
) => {
|
||||
const xMin = scaleX.min ?? -Infinity;
|
||||
const xMax = scaleX.max ?? Infinity;
|
||||
const yMin = scaleY.min ?? -Infinity;
|
||||
const yMax = scaleY.max ?? Infinity;
|
||||
|
||||
for (let i = idx0; i <= idx1; i++) {
|
||||
const x = dataX[i];
|
||||
const y = dataY[i];
|
||||
if (
|
||||
x == null ||
|
||||
y == null ||
|
||||
x < xMin ||
|
||||
x > xMax ||
|
||||
y < yMin ||
|
||||
y > yMax
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diameter =
|
||||
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
|
||||
const radius = diameter / 2;
|
||||
const cx = valToPosX(x, scaleX, xDim, xOff);
|
||||
const cy = valToPosY(y, scaleY, yDim, yOff);
|
||||
|
||||
path.moveTo(cx + radius, cy);
|
||||
arc(path, cx, cy, radius, 0, TWO_PI);
|
||||
|
||||
tree?.add({
|
||||
x: cx - radius - u.bbox.left,
|
||||
y: cy - radius - u.bbox.top,
|
||||
w: diameter,
|
||||
h: diameter,
|
||||
seriesIndex: seriesIdx,
|
||||
dataIndex: i,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { stroke: path, fill: path, clip: null };
|
||||
};
|
||||
|
||||
const cursor: uPlot.Cursor = {
|
||||
// Selection would set the dashboard time range; neither axis is time here.
|
||||
drag: { x: false, y: false, setScale: false },
|
||||
dataIdx: (u, seriesIdx): number | null => {
|
||||
// uPlot asks series 1..n in order on every cursor move; resolve once.
|
||||
if (seriesIdx === 1) {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const { pxRatio } = uPlot;
|
||||
hit =
|
||||
tree && left >= 0 && top >= 0
|
||||
? resolveHit(
|
||||
tree,
|
||||
left * pxRatio,
|
||||
top * pxRatio,
|
||||
hoverTolerance * pxRatio,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
|
||||
},
|
||||
points: {
|
||||
bbox: (_u, seriesIdx): uPlot.BBox => {
|
||||
if (hit?.seriesIndex !== seriesIdx) {
|
||||
return HIDDEN_BBOX;
|
||||
}
|
||||
const { pxRatio } = uPlot;
|
||||
return {
|
||||
left: hit.x / pxRatio,
|
||||
top: hit.y / pxRatio,
|
||||
width: hit.w / pxRatio,
|
||||
height: hit.h / pxRatio,
|
||||
};
|
||||
},
|
||||
},
|
||||
// uPlot only measures series that returned a data index, i.e. the hit one.
|
||||
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
|
||||
};
|
||||
|
||||
return {
|
||||
pathBuilder,
|
||||
cursor,
|
||||
hooks: {
|
||||
drawClear: (u: uPlot): void => {
|
||||
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
|
||||
// The tree only knows what the path builder last drew, so cached paths
|
||||
// must be rebuilt alongside it.
|
||||
u.series.forEach((series, index) => {
|
||||
if (index > 0) {
|
||||
(series as SeriesWithPaths)._paths = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy: (): void => {
|
||||
tree = null;
|
||||
hit = null;
|
||||
cachedData = null;
|
||||
cachedDomain = null;
|
||||
},
|
||||
},
|
||||
getHit: (): ScatterHit | null => hit,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyScatterPlugin(
|
||||
builder: UPlotConfigBuilder,
|
||||
plugin: ScatterPlugin,
|
||||
): void {
|
||||
builder.setMode(PlotMode.Faceted);
|
||||
builder.setCursor(plugin.cursor);
|
||||
builder.addHook('drawClear', plugin.hooks.drawClear);
|
||||
builder.addHook('destroy', plugin.hooks.destroy);
|
||||
}
|
||||
58
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
58
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { QuadtreeRect } from '../../utils/quadtree';
|
||||
|
||||
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
|
||||
export interface ScatterPointSize {
|
||||
fixed: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
|
||||
fixed: 6,
|
||||
min: 4,
|
||||
max: 24,
|
||||
};
|
||||
|
||||
/** CSS pixels around a point's disc that still register as a hover. */
|
||||
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
|
||||
|
||||
export interface ScatterPluginOptions {
|
||||
pointSize?: ScatterPointSize;
|
||||
hoverTolerance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One faceted series: parallel columns, one point per index. Sizes are in the
|
||||
* caller's units and mapped to `pointSize` at draw time; `null` draws at `fixed`.
|
||||
*/
|
||||
export type ScatterSeriesData = [
|
||||
xs: number[],
|
||||
ys: number[],
|
||||
sizes?: Array<number | null>,
|
||||
];
|
||||
|
||||
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
|
||||
export type ScatterChartData = [null, ...ScatterSeriesData[]];
|
||||
|
||||
/** A drawn point's disc, in canvas pixels relative to the plot area. */
|
||||
export interface ScatterHit extends QuadtreeRect {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
}
|
||||
|
||||
export interface ScatterChannel {
|
||||
label: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/** What each visual channel plots, for the tooltip and axes. */
|
||||
export interface ScatterChannels {
|
||||
x: ScatterChannel;
|
||||
y: ScatterChannel;
|
||||
size?: ScatterChannel;
|
||||
}
|
||||
|
||||
export interface ScatterPointLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
70
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
70
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Quadtree, QuadtreeRect } from '../quadtree';
|
||||
|
||||
interface Item extends QuadtreeRect {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function collect(
|
||||
tree: Quadtree<Item>,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
tree.get(x, y, w, h, (item) => ids.add(item.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('Quadtree', () => {
|
||||
it('returns items in the queried region and not those far from it', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
|
||||
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
|
||||
|
||||
// Below the split threshold every item is visited; callers refine the hit.
|
||||
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
|
||||
});
|
||||
|
||||
it('splits past the object limit and still finds every item', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
const total = 50;
|
||||
for (let id = 0; id < total; id++) {
|
||||
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
|
||||
}
|
||||
|
||||
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
|
||||
});
|
||||
|
||||
it('after a split, a query in one quadrant skips items confined to another', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
for (let id = 0; id < 20; id++) {
|
||||
// All in the north-west quadrant.
|
||||
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
|
||||
}
|
||||
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
|
||||
|
||||
const northWest = collect(tree, 0, 0, 10, 10);
|
||||
expect(northWest.has(99)).toBe(false);
|
||||
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an item straddling the midline from either side', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
for (let id = 0; id < 20; id++) {
|
||||
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
|
||||
}
|
||||
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
|
||||
|
||||
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
|
||||
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
|
||||
});
|
||||
|
||||
it('clear empties the tree', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
|
||||
tree.clear();
|
||||
|
||||
expect(collect(tree, 0, 0, 100, 100).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -195,3 +195,40 @@ describe('scale utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('symmetric log scale', () => {
|
||||
it('maps to uPlot arcsinh with the given linear threshold', () => {
|
||||
expect(
|
||||
scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 0.01,
|
||||
}),
|
||||
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
|
||||
|
||||
expect(
|
||||
scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.SymmetricLog,
|
||||
}).asinh,
|
||||
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
|
||||
});
|
||||
|
||||
it('ranges a distr 4 scale through uPlot.rangeAsinh', () => {
|
||||
const rangeAsinh = jest.fn(() => [-10, 1000] as uPlot.Range.MinMax);
|
||||
Object.assign(uPlot, { rangeAsinh });
|
||||
|
||||
const rangeFn = scaleUtils.createRangeFunction({
|
||||
rangeConfig: {} as uPlot.Range.Config,
|
||||
hardMinOnly: false,
|
||||
hardMaxOnly: false,
|
||||
hasFixedRange: false,
|
||||
min: null,
|
||||
max: null,
|
||||
});
|
||||
const u = { scales: { y: { distr: 4, log: 10 } } } as unknown as uPlot;
|
||||
|
||||
expect(rangeFn(u, -3, 700, 'y')).toStrictEqual([-10, 1000]);
|
||||
expect(rangeAsinh).toHaveBeenCalledWith(-3, 700, 10, true);
|
||||
});
|
||||
});
|
||||
|
||||
110
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
110
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export interface QuadtreeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
/**
|
||||
* Spatial index over axis-aligned rectangles, for answering "what is under the
|
||||
* cursor" on charts whose marks have no shared x order to binary-search. An item
|
||||
* straddling a quadrant boundary lives in every quadrant it touches, so `get` can
|
||||
* report it more than once.
|
||||
*/
|
||||
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
|
||||
private items: T[] = [];
|
||||
private quadrants: Quadtree<T>[] | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly x: number,
|
||||
private readonly y: number,
|
||||
private readonly w: number,
|
||||
private readonly h: number,
|
||||
private readonly level = 0,
|
||||
) {}
|
||||
|
||||
add(item: T): void {
|
||||
if (this.quadrants) {
|
||||
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.push(item);
|
||||
|
||||
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
|
||||
this.split();
|
||||
const items = this.items;
|
||||
this.items = [];
|
||||
for (const existing of items) {
|
||||
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
|
||||
get(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
visit: (item: T) => void,
|
||||
): void {
|
||||
for (const item of this.items) {
|
||||
visit(item);
|
||||
}
|
||||
if (this.quadrants) {
|
||||
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
|
||||
quadrant.get(x, y, w, h, visit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
this.quadrants = null;
|
||||
}
|
||||
|
||||
private split(): void {
|
||||
const w = this.w / 2;
|
||||
const h = this.h / 2;
|
||||
const level = this.level + 1;
|
||||
// North-east, north-west, south-west, south-east.
|
||||
this.quadrants = [
|
||||
new Quadtree<T>(this.x + w, this.y, w, h, level),
|
||||
new Quadtree<T>(this.x, this.y, w, h, level),
|
||||
new Quadtree<T>(this.x, this.y + h, w, h, level),
|
||||
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
|
||||
];
|
||||
}
|
||||
|
||||
private forEachQuadrant(
|
||||
rect: QuadtreeRect,
|
||||
visit: (quadrant: Quadtree<T>) => void,
|
||||
): void {
|
||||
if (!this.quadrants) {
|
||||
return;
|
||||
}
|
||||
const midX = this.x + this.w / 2;
|
||||
const midY = this.y + this.h / 2;
|
||||
const startsNorth = rect.y < midY;
|
||||
const startsWest = rect.x < midX;
|
||||
const endsEast = rect.x + rect.w > midX;
|
||||
const endsSouth = rect.y + rect.h > midY;
|
||||
|
||||
if (startsNorth && endsEast) {
|
||||
visit(this.quadrants[0]);
|
||||
}
|
||||
if (startsWest && startsNorth) {
|
||||
visit(this.quadrants[1]);
|
||||
}
|
||||
if (startsWest && endsSouth) {
|
||||
visit(this.quadrants[2]);
|
||||
}
|
||||
if (endsEast && endsSouth) {
|
||||
visit(this.quadrants[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,18 +58,23 @@ function normalizeLogLimit(
|
||||
return logBase ** exp;
|
||||
}
|
||||
|
||||
export const DEFAULT_ASINH_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Returns uPlot scale distribution options for the Y axis.
|
||||
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
|
||||
* Returns uPlot scale distribution options for a value axis.
|
||||
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
|
||||
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
|
||||
*/
|
||||
export function getDistributionConfig({
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold,
|
||||
}: {
|
||||
time: ScaleProps['time'];
|
||||
distr?: DistributionType;
|
||||
logBase?: number;
|
||||
asinhThreshold?: number;
|
||||
}): Partial<Scale> {
|
||||
if (time) {
|
||||
return {};
|
||||
@@ -77,6 +82,14 @@ export function getDistributionConfig({
|
||||
|
||||
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
|
||||
|
||||
if (distr === DistributionType.SymmetricLog) {
|
||||
return {
|
||||
distr: 4,
|
||||
log: resolvedLogBase,
|
||||
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
distr: distr === DistributionType.Logarithmic ? 3 : 1,
|
||||
log: resolvedLogBase,
|
||||
@@ -197,6 +210,33 @@ function getLogScaleRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the arcsinh-scale range using uPlot.rangeAsinh, which pads to whole
|
||||
* magnitudes on either side of zero and pins an edge that sits exactly on zero.
|
||||
*/
|
||||
function getAsinhScaleRange(
|
||||
minMax: Range.MinMax,
|
||||
params: RangeFunctionParams,
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
logBase?: uPlot.Scale['log'],
|
||||
): Range.MinMax {
|
||||
const { min, max } = params;
|
||||
const resolvedMin = min ?? dataMin;
|
||||
const resolvedMax = max ?? dataMax;
|
||||
|
||||
if (resolvedMin == null || resolvedMax == null) {
|
||||
return minMax;
|
||||
}
|
||||
|
||||
return uPlot.rangeAsinh(
|
||||
resolvedMin,
|
||||
resolvedMax,
|
||||
(logBase ?? 10) as 2 | 10,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
|
||||
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
|
||||
@@ -299,6 +339,8 @@ export function createRangeFunction(
|
||||
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
|
||||
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
|
||||
} else if (scale.distr === 4) {
|
||||
minMax = getAsinhScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
}
|
||||
|
||||
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import type { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
|
||||
import Scatter from './Scatter';
|
||||
import {
|
||||
buildScatterConfig,
|
||||
prepareScatterChartData,
|
||||
ScatterSeries,
|
||||
} from './utils';
|
||||
|
||||
const SERVICES = [
|
||||
'frontend',
|
||||
'cart',
|
||||
'checkout',
|
||||
'payment',
|
||||
'shipping',
|
||||
'currency',
|
||||
'email',
|
||||
'recommendation',
|
||||
'ads',
|
||||
'product-catalog',
|
||||
];
|
||||
|
||||
type Shape = 'spread' | 'single' | 'sameX';
|
||||
|
||||
interface ScatterStoryProps {
|
||||
groups: number;
|
||||
pointsPerGroup: number;
|
||||
/** Adds an error-count size column. */
|
||||
sized: boolean;
|
||||
xLog: boolean;
|
||||
yLog: boolean;
|
||||
/** Zeroes a share of y values, which forces the symmetric log. */
|
||||
withZeros: boolean;
|
||||
shape: Shape;
|
||||
thresholds: boolean;
|
||||
pointSize: number;
|
||||
/** 0–1. */
|
||||
fillOpacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Deterministic, so a story renders the same points on every run. */
|
||||
function createRng(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return (): number => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
function buildSeries({
|
||||
groups,
|
||||
pointsPerGroup,
|
||||
sized,
|
||||
withZeros,
|
||||
shape,
|
||||
}: ScatterStoryProps): ScatterSeries[] {
|
||||
const rng = createRng(42);
|
||||
return Array.from({ length: groups }, (_, groupIndex) => {
|
||||
const label = SERVICES[groupIndex % SERVICES.length];
|
||||
// Each service sits in its own throughput/latency band, so groups are telling
|
||||
// apart rather than one cloud.
|
||||
const baseThroughput = 20 * 2 ** (groupIndex % 5);
|
||||
const baseLatency = 40 + 60 * (groupIndex % 4);
|
||||
|
||||
const count = shape === 'single' ? 1 : pointsPerGroup;
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
const sizes: Array<number | null> = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const throughput =
|
||||
shape === 'sameX' ? baseThroughput : baseThroughput * (0.5 + rng() * 1.5);
|
||||
// Latency grows with load, plus noise; the odd outlier keeps the axis honest.
|
||||
const outlier = rng() < 0.03 ? 4 + rng() * 6 : 1;
|
||||
let latency =
|
||||
baseLatency *
|
||||
(0.8 + (throughput / baseThroughput) * 0.4 + rng() * 0.3) *
|
||||
outlier;
|
||||
if (withZeros && rng() < 0.2) {
|
||||
latency = 0;
|
||||
}
|
||||
xs.push(Number(throughput.toFixed(2)));
|
||||
ys.push(Number(latency.toFixed(2)));
|
||||
sizes.push(rng() < 0.1 ? null : Math.round(rng() * rng() * 500));
|
||||
}
|
||||
|
||||
return sized ? { label, xs, ys, sizes } : { label, xs, ys };
|
||||
});
|
||||
}
|
||||
|
||||
const THRESHOLDS: Threshold[] = [
|
||||
{
|
||||
thresholdValue: 300,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdColor: '#E5484D',
|
||||
thresholdLabel: 'p99 SLO',
|
||||
},
|
||||
];
|
||||
|
||||
function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
const {
|
||||
xLog,
|
||||
yLog,
|
||||
sized,
|
||||
thresholds,
|
||||
pointSize,
|
||||
fillOpacity,
|
||||
width,
|
||||
height,
|
||||
} = props;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [drawMs, setDrawMs] = useState<number | null>(null);
|
||||
|
||||
const series = useMemo(() => buildSeries(props), [props]);
|
||||
const pointCount = series.reduce((sum, entry) => sum + entry.xs.length, 0);
|
||||
const drawLabel = drawMs === null ? '—' : `${drawMs.toFixed(1)} ms`;
|
||||
|
||||
const config = useMemo(() => {
|
||||
const builder = buildScatterConfig({
|
||||
id: 'scatter-story',
|
||||
series,
|
||||
isDarkMode,
|
||||
x: { unit: 'reqps', isLogScale: xLog },
|
||||
y: { unit: 'ms', isLogScale: yLog },
|
||||
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
|
||||
fillOpacity,
|
||||
thresholds: thresholds ? THRESHOLDS : undefined,
|
||||
});
|
||||
let started = 0;
|
||||
builder.addHook('drawClear', (): void => {
|
||||
started = performance.now();
|
||||
});
|
||||
builder.addHook('draw', (): void => {
|
||||
setDrawMs(performance.now() - started);
|
||||
});
|
||||
return builder;
|
||||
}, [series, isDarkMode, xLog, yLog, pointSize, fillOpacity, thresholds]);
|
||||
|
||||
const data = useMemo(() => prepareScatterChartData(series), [series]);
|
||||
|
||||
const resolvePointLabels = (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
): ScatterPointLabel[] => [
|
||||
{ key: 'service.name', value: series[seriesIndex - 1]?.label ?? '' },
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
value: `pod-${dataIndex.toString().padStart(3, '0')}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width, padding: 16 }}>
|
||||
<Scatter
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={height}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
channels={{
|
||||
x: { label: 'Throughput', unit: 'reqps' },
|
||||
y: { label: 'p99 latency', unit: 'ms' },
|
||||
...(sized && { size: { label: 'Errors', unit: 'short' } }),
|
||||
}}
|
||||
resolvePointLabels={resolvePointLabels}
|
||||
canPinTooltip
|
||||
/>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, opacity: 0.7 }}>
|
||||
{`${pointCount.toLocaleString()} points · last draw ${drawLabel}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Charts/Scatter',
|
||||
component: ScatterStory,
|
||||
parameters: { layout: 'padded' },
|
||||
args: {
|
||||
groups: 1,
|
||||
pointsPerGroup: 10,
|
||||
sized: false,
|
||||
xLog: false,
|
||||
yLog: false,
|
||||
withZeros: false,
|
||||
shape: 'spread',
|
||||
thresholds: false,
|
||||
pointSize: 6,
|
||||
fillOpacity: 0.7,
|
||||
width: 800,
|
||||
height: 420,
|
||||
},
|
||||
argTypes: {
|
||||
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
|
||||
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
|
||||
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
|
||||
},
|
||||
} satisfies Meta<ScatterStoryProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ScatterStoryProps>;
|
||||
|
||||
/** One service, ten points: axes formatted with units, hover picks the right point. */
|
||||
export const Basic: Story = {};
|
||||
|
||||
/** Five services, one legend entry each; toggling a row hides its points. */
|
||||
export const Grouped: Story = {
|
||||
args: { groups: 5, pointsPerGroup: 40 },
|
||||
};
|
||||
|
||||
/** Error count as disc area, between the configured min and max diameters. */
|
||||
export const Sized: Story = {
|
||||
args: { groups: 5, pointsPerGroup: 40, sized: true, pointSize: 5 },
|
||||
};
|
||||
|
||||
/** Log x; a fifth of the latencies are 0, so y falls back to the symmetric log. */
|
||||
export const LogAxes: Story = {
|
||||
args: {
|
||||
groups: 5,
|
||||
pointsPerGroup: 60,
|
||||
xLog: true,
|
||||
yLog: true,
|
||||
withZeros: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** A single point still gets a padded range rather than an empty plot. */
|
||||
export const SinglePoint: Story = {
|
||||
args: { shape: 'single' },
|
||||
};
|
||||
|
||||
/** Fifty points sharing one x collide on nothing: no shared x array to align. */
|
||||
export const SameX: Story = {
|
||||
args: { groups: 3, pointsPerGroup: 50, shape: 'sameX' },
|
||||
};
|
||||
|
||||
/** Horizontal line with label on the y axis; the scale stretches to include it. */
|
||||
export const Thresholds: Story = {
|
||||
args: { groups: 3, pointsPerGroup: 40, thresholds: true },
|
||||
};
|
||||
|
||||
/** Perf harness: raise `pointsPerGroup` and read the draw time under the chart. */
|
||||
export const Dense: Story = {
|
||||
args: { groups: 5, pointsPerGroup: 1000, pointSize: 4, fillOpacity: 0.5 },
|
||||
};
|
||||
65
frontend/src/lib/visualization/charts/Scatter/Scatter.tsx
Normal file
65
frontend/src/lib/visualization/charts/Scatter/Scatter.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
|
||||
import {
|
||||
ScatterTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ScatterChartProps } from 'lib/visualization/charts/types';
|
||||
|
||||
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
|
||||
// mount; empty aligned data makes the shell show its no-data state instead.
|
||||
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
|
||||
|
||||
export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
}
|
||||
const tooltipProps: ScatterTooltipProps = {
|
||||
...args,
|
||||
id: rest.config.getId(),
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <ScatterTooltip {...tooltipProps} />;
|
||||
},
|
||||
[
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
rest.config,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const hasSeries = rest.data.length > 1;
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
buildScatterConfig,
|
||||
prepareScatterChartData,
|
||||
resolveAxisDistribution,
|
||||
ScatterSeries,
|
||||
} from '../utils';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}));
|
||||
|
||||
const SERIES: ScatterSeries[] = [
|
||||
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
|
||||
{ label: 'checkout', xs: [30], ys: [0] },
|
||||
];
|
||||
|
||||
describe('prepareScatterChartData', () => {
|
||||
it('lays series out as facets behind an empty x slot', () => {
|
||||
expect(prepareScatterChartData(SERIES)).toStrictEqual([
|
||||
null,
|
||||
[
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
[1, null],
|
||||
],
|
||||
[[30], [0]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAxisDistribution', () => {
|
||||
it('is linear unless log is asked for', () => {
|
||||
expect(resolveAxisDistribution([0, 1], false)).toStrictEqual({
|
||||
distribution: DistributionType.Linear,
|
||||
});
|
||||
});
|
||||
|
||||
it('is a plain log when every value is positive', () => {
|
||||
expect(resolveAxisDistribution([1, 100], true)).toStrictEqual({
|
||||
distribution: DistributionType.Logarithmic,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a symmetric log around the smallest magnitude when zero is present', () => {
|
||||
expect(resolveAxisDistribution([0, 0.05, 300], true)).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 0.01,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a unit threshold when nothing is positive', () => {
|
||||
expect(resolveAxisDistribution([0, -5], true)).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildScatterConfig', () => {
|
||||
const build = (
|
||||
overrides: Partial<Parameters<typeof buildScatterConfig>[0]> = {},
|
||||
): ReturnType<typeof buildScatterConfig> =>
|
||||
buildScatterConfig({
|
||||
id: 'scatter',
|
||||
series: SERIES,
|
||||
isDarkMode: true,
|
||||
x: { unit: 'reqps' },
|
||||
y: { unit: 'ms', isLogScale: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('emits a faceted plot with two value scales', () => {
|
||||
const config = build().getConfig();
|
||||
|
||||
expect(config.mode).toBe(2);
|
||||
expect(config.scales?.x).toMatchObject({ time: false, distr: 1 });
|
||||
// The y column has a 0, so log becomes the symmetric variant.
|
||||
expect(config.scales?.y).toMatchObject({ time: false, distr: 4 });
|
||||
});
|
||||
|
||||
it('draws one faceted series per group with the plugin path builder', () => {
|
||||
const config = build().getConfig();
|
||||
const [, cart, checkout] = config.series ?? [];
|
||||
|
||||
expect(config.series).toHaveLength(3);
|
||||
expect(cart).toMatchObject({
|
||||
label: 'cart',
|
||||
facets: [
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
],
|
||||
});
|
||||
expect(typeof cart?.paths).toBe('function');
|
||||
expect(cart?.paths).toBe(checkout?.paths);
|
||||
expect(cart?.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('formats both axes with their units', () => {
|
||||
const config = build().getConfig();
|
||||
const [xAxis, yAxis] = config.axes ?? [];
|
||||
|
||||
expect(xAxis).toMatchObject({ scale: 'x', side: 2, space: 90 });
|
||||
expect(yAxis).toMatchObject({ scale: 'y', side: 3 });
|
||||
expect(typeof xAxis?.values).toBe('function');
|
||||
expect(typeof yAxis?.values).toBe('function');
|
||||
});
|
||||
|
||||
it('registers a y threshold draw hook when thresholds are given', () => {
|
||||
const config = build({
|
||||
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.hooks?.draw).toHaveLength(1);
|
||||
expect(build().getConfig().hooks?.draw).toBeUndefined();
|
||||
});
|
||||
});
|
||||
209
frontend/src/lib/visualization/charts/Scatter/utils.ts
Normal file
209
frontend/src/lib/visualization/charts/Scatter/utils.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
import {
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterChartData,
|
||||
ScatterPointSize,
|
||||
ScatterSeriesData,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Circle outline; the fill carries the colour. */
|
||||
const POINT_STROKE_WIDTH = 1;
|
||||
|
||||
/** Unit-suffixed x labels are wider than uPlot's 50px default assumes. */
|
||||
const X_AXIS_TICK_SPACE_PX = 90;
|
||||
const X_AXIS_END_LABEL_PADDING_PX = 40;
|
||||
|
||||
export interface ScatterSeries {
|
||||
/** Group label, as the legend names it. */
|
||||
label: string;
|
||||
xs: number[];
|
||||
ys: number[];
|
||||
/** Optional third channel, in the caller's units. */
|
||||
sizes?: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface ScatterAxisOptions {
|
||||
unit?: string;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
isLogScale?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildScatterConfigArgs {
|
||||
id: string;
|
||||
series: ScatterSeries[];
|
||||
isDarkMode: boolean;
|
||||
x: ScatterAxisOptions;
|
||||
y: ScatterAxisOptions;
|
||||
pointSize?: ScatterPointSize;
|
||||
/** 0–1. */
|
||||
fillOpacity?: number;
|
||||
colorMapping?: Record<string, string>;
|
||||
/** Drawn on the y axis. */
|
||||
thresholds?: Threshold[];
|
||||
decimalPrecision?: PrecisionOption;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
|
||||
export function prepareScatterChartData(
|
||||
series: ScatterSeries[],
|
||||
): uPlot.AlignedData {
|
||||
const data: ScatterChartData = [
|
||||
null,
|
||||
...series.map(
|
||||
(entry): ScatterSeriesData =>
|
||||
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
|
||||
),
|
||||
];
|
||||
return data as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface AxisDistribution {
|
||||
distribution: DistributionType;
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A log axis needs every value above zero; a rate that is sometimes 0 would drop
|
||||
* those points. Zero or negatives switch to a symmetric log whose linear band
|
||||
* ends at the smallest non-zero magnitude, so nothing is lost and the small
|
||||
* values still spread out.
|
||||
*/
|
||||
export function resolveAxisDistribution(
|
||||
values: number[],
|
||||
isLogScale?: boolean,
|
||||
): AxisDistribution {
|
||||
if (!isLogScale) {
|
||||
return { distribution: DistributionType.Linear };
|
||||
}
|
||||
let minPositive = Infinity;
|
||||
let needsSymmetric = false;
|
||||
for (const value of values) {
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
if (value <= 0) {
|
||||
needsSymmetric = true;
|
||||
} else {
|
||||
minPositive = Math.min(minPositive, value);
|
||||
}
|
||||
}
|
||||
if (!needsSymmetric) {
|
||||
return { distribution: DistributionType.Logarithmic };
|
||||
}
|
||||
const asinhThreshold = Number.isFinite(minPositive)
|
||||
? 10 ** Math.floor(Math.log10(minPositive))
|
||||
: 1;
|
||||
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
|
||||
}
|
||||
|
||||
export function buildScatterConfig({
|
||||
id,
|
||||
series,
|
||||
isDarkMode,
|
||||
x,
|
||||
y,
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
fillOpacity,
|
||||
colorMapping = {},
|
||||
thresholds,
|
||||
decimalPrecision,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
}: BuildScatterConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
});
|
||||
|
||||
const plugin = createScatterPlugin({ pointSize });
|
||||
applyScatterPlugin(builder, plugin);
|
||||
// The last x label is centred on the plot's right edge; room for its unit.
|
||||
builder.setPadding([16, X_AXIS_END_LABEL_PADDING_PX, 8, 8]);
|
||||
|
||||
const xDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.xs),
|
||||
x.isLogScale,
|
||||
);
|
||||
const yDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.ys),
|
||||
y.isLogScale,
|
||||
);
|
||||
|
||||
const yThresholds =
|
||||
thresholds && thresholds.length > 0
|
||||
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
|
||||
: undefined;
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
softMin: x.softMin ?? undefined,
|
||||
softMax: x.softMax ?? undefined,
|
||||
...xDistribution,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
softMin: y.softMin ?? undefined,
|
||||
softMax: y.softMax ?? undefined,
|
||||
thresholds: yThresholds,
|
||||
...yDistribution,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isTimeAxis: false,
|
||||
yAxisUnit: x.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: xDistribution.distribution !== DistributionType.Linear,
|
||||
space: X_AXIS_TICK_SPACE_PX,
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: y.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: yDistribution.distribution !== DistributionType.Linear,
|
||||
});
|
||||
|
||||
series.forEach((entry) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: entry.label,
|
||||
colorMapping,
|
||||
drawStyle: DrawStyle.Scatter,
|
||||
pathBuilder: plugin.pathBuilder,
|
||||
facets: SCATTER_FACETS,
|
||||
lineWidth: POINT_STROKE_WIDTH,
|
||||
pointSize: pointSize.fixed,
|
||||
fillOpacity,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
if (yThresholds) {
|
||||
builder.addThresholds(yThresholds);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -74,6 +78,15 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
|
||||
export interface ScatterChartProps extends ChartWrapperProps {
|
||||
channels: ScatterChannels;
|
||||
resolvePointLabels?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -271,40 +270,3 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
|
||||
interface StorySpeechRecognition {
|
||||
listening: { hear: (transcript: string) => void } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the composer's mic and waits for the recording to start. The composer
|
||||
* stays disabled until the thread has loaded.
|
||||
*/
|
||||
export const startVoiceInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const mic = await waitFor(
|
||||
async () => {
|
||||
const button = canvas.getByRole('button', { name: /start voice input/i });
|
||||
|
||||
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
|
||||
await expect(button).toBeEnabled();
|
||||
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return button;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(mic);
|
||||
await canvas.findByRole('status', { name: /recording voice input/i });
|
||||
};
|
||||
|
||||
/** Speaks into the open recording: the words so far, still listening. */
|
||||
export const speak = (transcript: string): void => {
|
||||
(
|
||||
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
|
||||
).webkitSpeechRecognition.listening?.hear(transcript);
|
||||
};
|
||||
|
||||
@@ -10,8 +10,6 @@ import AIAssistantPage from '../AIAssistantPage';
|
||||
import {
|
||||
aiAssistantMocks,
|
||||
longActionTooltipHandlers,
|
||||
speak,
|
||||
startVoiceInput,
|
||||
} from './AIAssistantPage.stories.mocks';
|
||||
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
|
||||
|
||||
@@ -175,18 +173,6 @@ export const ApprovalDiff: Story = {
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dictating a question: the mic listening, the words heard so far in the
|
||||
* composer, and the controls to discard them or stop and send.
|
||||
*/
|
||||
export const VoiceRecording: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
speak('why did checkout p99 jump after the 14:00 deploy');
|
||||
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The comment box a thumbs down opens, which a thumbs up does not. */
|
||||
export const NegativeFeedback: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
@@ -257,13 +243,3 @@ export const TooltipsInApprovalDiff: Story = {
|
||||
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* The recording controls' tooltips, held open: discard, stop and send, and the
|
||||
* send button beside them, which waits for words.
|
||||
*/
|
||||
export const TooltipsInVoiceRecording: Story = {
|
||||
args: { tooltipsOpen: true, contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> =>
|
||||
startVoiceInput(canvasElement),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,8 +21,7 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Overview',
|
||||
tags: ['authz'],
|
||||
title: 'Pages/Dashboards/Panel Editor',
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
@@ -50,36 +49,6 @@ export const NewPanel: Story = {
|
||||
args: { panel: 'new' },
|
||||
};
|
||||
|
||||
/** A number panel: one value, formatted, with comparison thresholds. */
|
||||
export const NumberPanel: Story = {
|
||||
args: { panel: 'p99-latency' },
|
||||
};
|
||||
|
||||
/** A bar chart, whose Visualization section offers stacking. */
|
||||
export const BarChartPanel: Story = {
|
||||
args: { panel: 'errors-by-status' },
|
||||
};
|
||||
|
||||
/** A pie chart, with a legend per slice and no axes. */
|
||||
export const PieChartPanel: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
};
|
||||
|
||||
/** A new area chart: the time series options with the area filled. */
|
||||
export const AreaChartPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
};
|
||||
|
||||
/** A new histogram, where the display options are its buckets. */
|
||||
export const HistogramPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
};
|
||||
|
||||
/** A new text panel: a markdown body in place of the query builder. */
|
||||
export const TextPanel: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
};
|
||||
|
||||
/** A list panel, where the config pane is the column editor. */
|
||||
export const ListPanel: Story = {
|
||||
args: { panel: 'recent-logs' },
|
||||
|
||||
@@ -22,13 +22,11 @@ import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKi
|
||||
export const NEW_PANEL_KINDS = [
|
||||
'time-series',
|
||||
'bar-chart',
|
||||
'area-chart',
|
||||
'number',
|
||||
'pie-chart',
|
||||
'table',
|
||||
'histogram',
|
||||
'list',
|
||||
'text',
|
||||
] as const;
|
||||
|
||||
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
@@ -36,13 +34,11 @@ export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
|
||||
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
|
||||
'time-series': 'signoz/TimeSeriesPanel',
|
||||
'bar-chart': 'signoz/BarChartPanel',
|
||||
'area-chart': 'signoz/AreaChartPanel',
|
||||
number: 'signoz/NumberPanel',
|
||||
'pie-chart': 'signoz/PieChartPanel',
|
||||
table: 'signoz/TablePanel',
|
||||
histogram: 'signoz/HistogramPanel',
|
||||
list: 'signoz/ListPanel',
|
||||
text: 'signoz/TextPanel',
|
||||
};
|
||||
|
||||
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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 } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from '../PanelEditorPage.stories.mocks';
|
||||
import {
|
||||
expandSection,
|
||||
openConfigSelect,
|
||||
pickSegment,
|
||||
} from './panelEditorPlay';
|
||||
|
||||
import PanelEditorPage from '../../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor's display options, one section open at a time: what each
|
||||
* panel kind lets a user tune about how its data is drawn, and the preview
|
||||
* following along.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Chart Options',
|
||||
tags: ['authz', 'play'],
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/** The panel type picker, open on the kinds this query can be drawn as. */
|
||||
export const PanelTypeOpen: Story = {
|
||||
play: async () => {
|
||||
await openConfigSelect('panel-editor-v2-type-switcher');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The time range the panel reads, open on its choices. */
|
||||
export const TimePreferenceOpen: Story = {
|
||||
play: async () => {
|
||||
await openConfigSelect('panel-editor-v2-time-preference');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Formatting & Units: the unit and decimals the values are shown with. */
|
||||
export const Formatting: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Formatting & Units');
|
||||
await screen.findByTestId('panel-editor-v2-decimals');
|
||||
},
|
||||
};
|
||||
|
||||
/** The unit picker, open on its categories. */
|
||||
export const UnitPickerOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Formatting.play?.(context);
|
||||
await openConfigSelect('panel-editor-v2-unit');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Axes: soft bounds for the y axis and its scale. */
|
||||
export const Axes: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Axes');
|
||||
await screen.findByTestId('panel-editor-v2-log-scale');
|
||||
},
|
||||
};
|
||||
|
||||
/** The y axis on a log scale, which the preview redraws with. */
|
||||
export const AxesLogScale: Story = {
|
||||
play: async (context) => {
|
||||
await Axes.play?.(context);
|
||||
await pickSegment('panel-editor-v2-log-scale', 'Log');
|
||||
},
|
||||
};
|
||||
|
||||
/** Legend: its position, and a color per series the query returned. */
|
||||
export const Legend: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Legend');
|
||||
await screen.findByTestId('panel-editor-v2-legend-position');
|
||||
},
|
||||
};
|
||||
|
||||
/** The legend moved to the right of the preview. */
|
||||
export const LegendRight: Story = {
|
||||
play: async (context) => {
|
||||
await Legend.play?.(context);
|
||||
await pickSegment('panel-editor-v2-legend-position', 'Right');
|
||||
},
|
||||
};
|
||||
|
||||
/** Chart appearance: line style, interpolation, fill and points. */
|
||||
export const ChartAppearance: Story = {
|
||||
play: async () => {
|
||||
await expandSection('Chart appearance');
|
||||
await screen.findByTestId('panel-editor-v2-line-style');
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashed lines under a gradient fill, with the points drawn. */
|
||||
export const ChartAppearanceDashedGradient: Story = {
|
||||
play: async (context) => {
|
||||
await ChartAppearance.play?.(context);
|
||||
await pickSegment('panel-editor-v2-line-style', 'Dashed');
|
||||
await pickSegment('panel-editor-v2-fill-mode', 'Gradient');
|
||||
const points = screen.getByTestId('panel-editor-v2-show-points');
|
||||
|
||||
await userEvent.click(points);
|
||||
await waitFor(() => expect(points).toHaveAttribute('aria-checked', 'true'));
|
||||
},
|
||||
};
|
||||
|
||||
/** An area chart's appearance, which adds the fill opacity. */
|
||||
export const AreaChartAppearance: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'area-chart' },
|
||||
play: async () => {
|
||||
await expandSection('Chart appearance');
|
||||
await screen.findByTestId('panel-editor-v2-fill-opacity');
|
||||
},
|
||||
};
|
||||
|
||||
/** A bar chart with its series stacked on each other. */
|
||||
export const BarStacked: Story = {
|
||||
args: { panel: 'errors-by-status' },
|
||||
play: async () => {
|
||||
await expandSection('Visualization');
|
||||
const stack = await screen.findByTestId(
|
||||
'panel-editor-v2-stacked-bar-chart',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(stack);
|
||||
await waitFor(() => expect(stack).toHaveAttribute('aria-checked', 'true'));
|
||||
},
|
||||
};
|
||||
|
||||
/** A table's formatting: a unit per value column. */
|
||||
export const TableColumnUnits: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
play: async () => {
|
||||
await expandSection('Formatting & Units');
|
||||
await screen.findByTestId('panel-editor-v2-decimals');
|
||||
},
|
||||
};
|
||||
|
||||
/** A pie chart's legend, colored per slice. */
|
||||
export const PieLegend: Story = {
|
||||
args: { panel: 'traffic-share' },
|
||||
play: async () => {
|
||||
await expandSection('Legend');
|
||||
await screen.findByTestId('panel-editor-v2-legend-position');
|
||||
},
|
||||
};
|
||||
|
||||
/** A histogram's buckets: how many, how wide, and whether queries merge. */
|
||||
export const HistogramBuckets: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'histogram' },
|
||||
play: async () => {
|
||||
await expandSection('Histogram / Buckets');
|
||||
await screen.findByTestId('panel-editor-v2-bucket-count');
|
||||
},
|
||||
};
|
||||
|
||||
/** A text panel's appearance: alignment and background. */
|
||||
export const TextAppearance: Story = {
|
||||
args: { panel: 'new', newPanelKind: 'text' },
|
||||
play: async () => {
|
||||
await expandSection('Panel appearance');
|
||||
await screen.findByTestId('text-layout-horizontal-align');
|
||||
},
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
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 } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { panelEditorMocks } from '../PanelEditorPage.stories.mocks';
|
||||
import { addThreshold, openLinkDialog } from './panelEditorPlay';
|
||||
|
||||
import PanelEditorPage from '../../PanelEditorPage';
|
||||
|
||||
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
|
||||
|
||||
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The panel editor's Thresholds and Context Links sections. A threshold row
|
||||
* takes the shape of its panel kind: a labelled line on a chart, a comparison
|
||||
* on a number, a column rule on a table. A context link opens a dialog whose
|
||||
* URL can carry the dashboard's variables.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId/panel/:panelId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Panel Editor/Thresholds and Links',
|
||||
tags: ['authz', 'play'],
|
||||
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<PanelEditorArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<PanelEditorArgs>;
|
||||
|
||||
/** A time series threshold: a value, a color and a label drawn on the chart. */
|
||||
export const Threshold: Story = {
|
||||
play: async () => {
|
||||
await addThreshold();
|
||||
await screen.findByTestId('threshold-value-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** The threshold's color picker, open on the palette presets. */
|
||||
export const ThresholdColorOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Threshold.play?.(context);
|
||||
await userEvent.click(screen.getByTestId('threshold-color-0'));
|
||||
await screen.findByText('Defaults');
|
||||
},
|
||||
};
|
||||
|
||||
/** A number panel's threshold, which compares the value and recolors it. */
|
||||
export const NumberThreshold: Story = {
|
||||
args: { panel: 'p99-latency' },
|
||||
play: async () => {
|
||||
await addThreshold();
|
||||
await screen.findByTestId('comparison-threshold-operator-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** A table's threshold, which picks the column it colors. */
|
||||
export const TableThreshold: Story = {
|
||||
args: { panel: 'top-endpoints' },
|
||||
play: async () => {
|
||||
await addThreshold();
|
||||
await screen.findByTestId('table-threshold-column-0');
|
||||
},
|
||||
};
|
||||
|
||||
/** The add-link dialog, empty, with Save waiting for a URL. */
|
||||
export const ContextLinkDialog: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
},
|
||||
};
|
||||
|
||||
/** The URL field focused, offering the variables it can interpolate. */
|
||||
export const ContextLinkVariables: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await screen.findByTestId('context-link-variable-service');
|
||||
},
|
||||
};
|
||||
|
||||
/** A URL with query parameters, split into rows that can be edited one by one. */
|
||||
export const ContextLinkParams: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.type(
|
||||
screen.getByTestId('context-link-label'),
|
||||
'Open service traces',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await userEvent.paste(
|
||||
'/traces-explorer?service={{service}}&env={{environment}}',
|
||||
);
|
||||
// Leaving the URL closes the variables list that covers the rows.
|
||||
await userEvent.click(screen.getByTestId('context-link-label'));
|
||||
await screen.findByTestId('context-link-param-key-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** A URL the link cannot open, which the field flags and Save refuses. */
|
||||
export const ContextLinkInvalidUrl: Story = {
|
||||
play: async () => {
|
||||
await openLinkDialog();
|
||||
await userEvent.click(screen.getByTestId('context-link-url'));
|
||||
await userEvent.paste('traces for {{service}}');
|
||||
await screen.findByTestId('context-link-url-error');
|
||||
},
|
||||
};
|
||||
|
||||
/** A saved link, listed in the section with its edit and remove actions. */
|
||||
export const ContextLinkSaved: Story = {
|
||||
play: async (context) => {
|
||||
await ContextLinkParams.play?.(context);
|
||||
await userEvent.click(screen.getByTestId('context-link-save'));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('context-link-dialog')).toBeNull(),
|
||||
);
|
||||
await screen.findByTestId('context-link-item-0');
|
||||
},
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
// The editor resolves its panel from the dashboard document before it renders.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
/**
|
||||
* Opens a display-options section and scrolls it to the top of the pane, where
|
||||
* a screenshot of the viewport can see it. Returns the section to query in.
|
||||
*/
|
||||
export const expandSection = async (title: string): Promise<HTMLElement> => {
|
||||
const toggle = await screen.findByRole('button', { name: title }, PAGE_LOAD);
|
||||
|
||||
if (toggle.getAttribute('aria-expanded') !== 'true') {
|
||||
await userEvent.click(toggle);
|
||||
}
|
||||
toggle.scrollIntoView({ block: 'start' });
|
||||
|
||||
return toggle.closest('section') ?? document.body;
|
||||
};
|
||||
|
||||
/** Picks one option of a segmented control by the label it shows. */
|
||||
export const pickSegment = async (
|
||||
testId: string,
|
||||
label: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(within(screen.getByTestId(testId)).getByText(label));
|
||||
};
|
||||
|
||||
/**
|
||||
* The pane's pickers are antd Selects: the element carrying the test id does
|
||||
* nothing on click, the combobox inside it is what opens the list.
|
||||
*/
|
||||
export const openConfigSelect = async (testId: string): Promise<void> => {
|
||||
const select = await screen.findByTestId(testId, {}, PAGE_LOAD);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
/** Opens Thresholds and adds one row through the section's own button. */
|
||||
export const addThreshold = async (): Promise<void> => {
|
||||
const section = await expandSection('Thresholds');
|
||||
|
||||
await userEvent.click(
|
||||
within(section).getByRole('button', { name: 'Add threshold' }),
|
||||
);
|
||||
};
|
||||
|
||||
export const openLinkDialog = async (): Promise<void> => {
|
||||
const section = await expandSection('Context Links');
|
||||
|
||||
// The header's quick add carries the same name, so the body button goes by id.
|
||||
await userEvent.click(within(section).getByTestId('panel-editor-v2-add-link'));
|
||||
await screen.findByTestId('context-link-dialog');
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import { rest } from 'msw';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
|
||||
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
@@ -16,15 +15,11 @@ 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 {
|
||||
currentDashboardDocument,
|
||||
cyclicVariablesDashboardResponse,
|
||||
patchDashboardDocument,
|
||||
PANEL_IDS,
|
||||
seedDashboardDocument,
|
||||
@@ -34,9 +29,8 @@ import {
|
||||
type VariableKind,
|
||||
} from './__story_mockdata__/dashboard';
|
||||
import {
|
||||
attributeValues,
|
||||
emptyPanelResponse,
|
||||
VARIABLE_ATTRIBUTES,
|
||||
NAMESPACE_VALUES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from './__story_mockdata__/panelData';
|
||||
@@ -71,14 +65,6 @@ const NOT_FOUND = {
|
||||
|
||||
const ok = { status: 'success', data: null };
|
||||
|
||||
// The editor rewrites any `Syntax error:` into its own hint, so the ClickHouse
|
||||
// wording only shows through the variable bar.
|
||||
const VARIABLE_QUERY_ERROR = {
|
||||
status: 'error',
|
||||
error:
|
||||
"Code: 62. DB::Exception: Syntax error: failed at position 58 ('$environment'). (SYNTAX_ERROR)",
|
||||
};
|
||||
|
||||
const publicMeta = (): GetPublicDashboard200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
@@ -151,12 +137,6 @@ export const dashboardMocks = defineStoryMocks({
|
||||
value: 4,
|
||||
max: 12,
|
||||
}),
|
||||
variableQueryFails: toggleControl('Variable query fails', {
|
||||
group: DATA,
|
||||
description:
|
||||
"The query-backed `service` variable answers with a ClickHouse syntax error, which the variable bar and the editor's Test Run both report.",
|
||||
value: false,
|
||||
}),
|
||||
noData: toggleControl('Panels return nothing', {
|
||||
group: DATA,
|
||||
description:
|
||||
@@ -245,31 +225,17 @@ export const dashboardMocks = defineStoryMocks({
|
||||
}),
|
||||
),
|
||||
|
||||
values.variableQueryFails
|
||||
? rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
|
||||
res(ctx.status(400), ctx.json(VARIABLE_QUERY_ERROR)),
|
||||
)
|
||||
: rest.post(
|
||||
'http://localhost/api/v2/variables/query',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
variableValues: serviceVariableValues(values.variableValues),
|
||||
},
|
||||
})),
|
||||
),
|
||||
rest.post(
|
||||
'http://localhost/api/v2/variables/query',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { variableValues: serviceVariableValues(values.variableValues) },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
fieldValuesResponse(attributeValues(req.url.searchParams.get('name'))),
|
||||
),
|
||||
),
|
||||
|
||||
// The dynamic variable editor lists the attributes a variable can read.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
|
||||
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
@@ -303,9 +269,6 @@ export const dashboardMocks = defineStoryMocks({
|
||||
variables: values.variables,
|
||||
locked: values.locked,
|
||||
});
|
||||
// The sync mode persists per dashboard, so one story's pick would open the
|
||||
// next one on it.
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,9 +327,3 @@ export const metricsListHandler = rest.get(
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: { metrics: [] } })),
|
||||
);
|
||||
|
||||
export const cyclicVariablesDashboardHandler = rest.get(
|
||||
'http://localhost/api/v2/dashboards/:id',
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(cyclicVariablesDashboardResponse())),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -30,8 +30,8 @@ const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
title: 'Pages/Dashboards/Detail',
|
||||
tags: ['role-gated', 'play'],
|
||||
// The page is wrapped in `withAuthZPage`, which types its props as an index
|
||||
// signature; the story's args are what the controls resolve to.
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
@@ -159,6 +159,53 @@ export const TooltipsInJsonDrawer: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
|
||||
* syncing the crosshair does and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
|
||||
* all says whether it is already a filter on every panel. The row keeps its
|
||||
* actions invisible until it is hovered, which the story does first.
|
||||
*/
|
||||
export const TooltipsInVariableSettings: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.click(
|
||||
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel expanded into view mode, whose header carries the full panel name its
|
||||
* title truncates, over the dashboard's own tooltips behind the dialog.
|
||||
@@ -238,18 +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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
|
||||
@@ -336,28 +336,6 @@ const VARIABLES: Record<VariableKind, DashboardtypesVariableDTO> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `service` already filters on `$environment`, so an `environment` that reads
|
||||
* `$service` back closes the loop. An imported dashboard can arrive like this;
|
||||
* the editor only refuses it on save.
|
||||
*/
|
||||
const CYCLIC_ENVIRONMENT: DashboardtypesVariableDTO = {
|
||||
kind: ListVariableKind.ListVariable,
|
||||
spec: {
|
||||
name: 'environment',
|
||||
display: { name: 'environment' },
|
||||
allowMultiple: false,
|
||||
allowAllValue: false,
|
||||
plugin: {
|
||||
kind: QueryVariableKind['signoz/QueryVariable'],
|
||||
spec: {
|
||||
queryValue:
|
||||
'SELECT DISTINCT env FROM signoz_metrics WHERE service_name IN $service',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export interface PanelQueryShape {
|
||||
requestType: string;
|
||||
metricName?: string;
|
||||
@@ -473,18 +451,3 @@ export const patchDashboardDocument = (
|
||||
|
||||
return envelope(document);
|
||||
};
|
||||
|
||||
export const cyclicVariablesDashboardResponse = (): GetDashboardV2200 => {
|
||||
const response = dashboardResponse({
|
||||
panels: PANEL_IDS.length,
|
||||
sectioned: true,
|
||||
variables: VARIABLE_KINDS,
|
||||
locked: false,
|
||||
});
|
||||
|
||||
response.data.spec.variables = response.data.spec.variables.map((variable) =>
|
||||
variable === VARIABLES.custom ? CYCLIC_ENVIRONMENT : variable,
|
||||
);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -173,20 +173,3 @@ export const NAMESPACE_VALUES = [
|
||||
'payments-prod',
|
||||
'platform-prod',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The attributes the dynamic variable editor offers a variable to read, with
|
||||
* the values each one resolves to.
|
||||
*/
|
||||
const ATTRIBUTE_VALUES: Record<string, readonly string[]> = {
|
||||
'k8s.namespace.name': NAMESPACE_VALUES,
|
||||
'k8s.cluster.name': ['prod-us-east-1', 'prod-eu-west-1'],
|
||||
'service.name': SERVICES,
|
||||
'deployment.environment': ['production', 'staging', 'development'],
|
||||
'host.name': ['ip-10-0-1-12', 'ip-10-0-1-37', 'ip-10-0-2-4'],
|
||||
};
|
||||
|
||||
export const VARIABLE_ATTRIBUTES = Object.keys(ATTRIBUTE_VALUES);
|
||||
|
||||
export const attributeValues = (name: string | null): readonly string[] =>
|
||||
ATTRIBUTE_VALUES[name ?? ''] ?? NAMESPACE_VALUES;
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
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 { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
expectDisabled,
|
||||
menuItem,
|
||||
openActionsMenu,
|
||||
toolbarButton,
|
||||
} from './authzPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The dashboard with one permission missing at a time. Every story here is an
|
||||
* admin denied exactly what its name says. `read` decides whether the page
|
||||
* renders at all; `update` is every edit, the settings drawer included, so
|
||||
* Configure is as far as a user without it gets.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Authz',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
const READ = 'read:dashboard';
|
||||
const UPDATE = 'update:dashboard';
|
||||
const DELETE = 'delete:dashboard';
|
||||
const CREATE = 'create:dashboard';
|
||||
|
||||
/** The page refuses to render: `read` is the one check it mounts on. */
|
||||
export const NoRead: Story = {
|
||||
args: { revoked: [READ] },
|
||||
play: async () => {
|
||||
await screen.findByText('Uh-oh! You are not authorized', undefined, {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The panels still render, and Configure and New Panel stay in the toolbar,
|
||||
* disabled.
|
||||
*/
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'New Panel'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The same toolbar with its tooltips open, each naming what is missing. */
|
||||
export const TooltipsWithoutUpdate: Story = {
|
||||
args: { revoked: [UPDATE], tooltipsOpen: true },
|
||||
play: NoUpdate.play,
|
||||
};
|
||||
|
||||
/**
|
||||
* A locked dashboard the user could not have edited anyway: the tooltips name
|
||||
* the permission rather than the lock.
|
||||
*/
|
||||
export const TooltipsWithoutUpdateWhenLocked: Story = {
|
||||
args: { revoked: [UPDATE], locked: true, tooltipsOpen: true },
|
||||
play: NoUpdate.play,
|
||||
};
|
||||
|
||||
/** The Actions menu: rename, lock and new section locked; clone and delete open. */
|
||||
export const NoUpdateActionsMenu: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Rename'));
|
||||
await expectDisabled(menuItem('Lock dashboard'));
|
||||
await expectDisabled(menuItem('New section'));
|
||||
await expectDisabled(menuItem('Clone dashboard'), false);
|
||||
await expectDisabled(menuItem('Delete dashboard'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** A panel's menu: view and download stay, every edit is locked. */
|
||||
export const NoUpdatePanelMenu: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'panel-actions-p99-latency',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
const menu = await screen.findByRole('menu');
|
||||
|
||||
await expectDisabled(
|
||||
within(menu).getByRole('menuitem', { name: /Edit panel/ }),
|
||||
);
|
||||
await expectDisabled(
|
||||
within(menu).getByRole('menuitem', { name: /Delete panel/ }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The JSON editor opens read-only, and Apply says why it is locked. */
|
||||
export const NoUpdateJsonEditor: Story = {
|
||||
args: { revoked: [UPDATE], tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'edit-json',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await expectDisabled(await screen.findByTestId('json-editor-apply'));
|
||||
},
|
||||
};
|
||||
|
||||
/** A blank dashboard whose add-panel prompts are locked. */
|
||||
export const NoUpdateEmpty: Story = {
|
||||
args: { revoked: [UPDATE], panels: 0, sectioned: false },
|
||||
play: async () => {
|
||||
await expectDisabled(
|
||||
await screen.findByTestId('add-panel', {}, { timeout: 10000 }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete dashboard locked in the Actions menu; every edit still works. */
|
||||
export const NoDelete: Story = {
|
||||
args: { revoked: [DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Delete dashboard'));
|
||||
await expectDisabled(menuItem('Rename'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** Cloning is a create, so the copy is locked while the original stays editable. */
|
||||
export const NoCreate: Story = {
|
||||
args: { revoked: [CREATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Clone dashboard'));
|
||||
await expectDisabled(menuItem('Rename'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** Everything but reading: the Actions menu with only full screen left. */
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [UPDATE, DELETE, CREATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openActionsMenu(canvasElement);
|
||||
await expectDisabled(menuItem('Clone dashboard'));
|
||||
await expectDisabled(menuItem('Delete dashboard'));
|
||||
await expectDisabled(menuItem('Full screen'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The page holds on its spinner until the permission check answers. The
|
||||
* spinner's `Loading dashboard...` tip never shows: antd only renders a tip
|
||||
* around nested content.
|
||||
*/
|
||||
export const CheckLoading: Story = {
|
||||
args: { authzState: 'loading' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await waitFor(() =>
|
||||
expect(canvasElement.querySelector('.ant-spin')).not.toBeNull(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check failing rather than denying: the page renders, and
|
||||
* every edit stays locked as if `update` were denied.
|
||||
*/
|
||||
export const CheckFailed: Story = {
|
||||
args: { authzState: 'error' },
|
||||
// The mocked check intentionally fails; the resulting console error is the
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
|
||||
},
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
// The permission check resolves after the dashboard document loads.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
/**
|
||||
* A denied control stays on screen, disabled. Buttons say so through
|
||||
* `disabled` or `aria-disabled` depending on the Button, menu rows through
|
||||
* `data-disabled`.
|
||||
*/
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true' ||
|
||||
element.hasAttribute('data-disabled');
|
||||
|
||||
export const expectDisabled = (
|
||||
element: HTMLElement,
|
||||
disabled = true,
|
||||
): Promise<void> =>
|
||||
waitFor(() => expect(isDisabled(element)).toBe(disabled), PAGE_LOAD);
|
||||
|
||||
export const toolbarButton = (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<HTMLElement> =>
|
||||
within(canvasElement).findByRole('button', { name }, PAGE_LOAD);
|
||||
|
||||
export const openActionsMenu = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
// The dropdown trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.click(await toolbarButton(canvasElement, 'Actions'));
|
||||
await screen.findByText('Clone dashboard');
|
||||
};
|
||||
|
||||
// Not by test id: the Actions menu only carries them on some Dropdown versions.
|
||||
export const menuItem = (name: string): HTMLElement =>
|
||||
screen.getByRole('menuitem', { name });
|
||||
@@ -1,131 +0,0 @@
|
||||
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 } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
cyclicVariablesDashboardHandler,
|
||||
dashboardMocks,
|
||||
} from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
findPreviewValue,
|
||||
openVariableEditor,
|
||||
openVariableSelect,
|
||||
} from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The variable editor on each of the dashboard's variables, reached from a
|
||||
* row's edit action in the Variables tab: what every type saved, the values it
|
||||
* resolves to, and the query failures and dependency loops it reports.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Edit Variable',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/**
|
||||
* `$environment`: a comma-separated list with one value by default. The preview
|
||||
* stays empty until the options are edited.
|
||||
*/
|
||||
export const Custom: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await screen.findByDisplayValue('production,staging,development');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$service`: a ClickHouse query that reads `$environment`, run once on open so
|
||||
* the preview and default value have its values.
|
||||
*/
|
||||
export const Query: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await findPreviewValue('inventory');
|
||||
},
|
||||
};
|
||||
|
||||
/** `$service` with its query failing, which the preview reports in its place. */
|
||||
export const QueryFailed: Story = {
|
||||
args: { variableQueryFails: true },
|
||||
// The failed variable query is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await screen.findByText(
|
||||
'Please make sure query is valid and dependent variables are selected',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The sort picker, open over the previewed values it reorders. */
|
||||
export const SortOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Query.play?.(context);
|
||||
await openVariableSelect('variable-sort-select');
|
||||
await screen.findByTitle('Alphabetical (descending)');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$namespace`: a dynamic variable reading `k8s.namespace.name` from metrics,
|
||||
* with the panels it could be applied to.
|
||||
*/
|
||||
export const Dynamic: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'namespace');
|
||||
await findPreviewValue('payments-prod');
|
||||
},
|
||||
};
|
||||
|
||||
/** `$owner`: a Textbox variable with its default value. */
|
||||
export const Textbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'owner');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('variable-text-input')).toHaveValue(
|
||||
'platform-team',
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `$environment` and `$service` reading each other, as an imported dashboard
|
||||
* can arrive: saving either one is refused and names the loop.
|
||||
*
|
||||
* The document is answered by the story, so the Panels, Sections, Variables and
|
||||
* Locked controls do not reach it.
|
||||
*/
|
||||
export const CircularDependency: Story = {
|
||||
parameters: { msw: { handlers: [cyclicVariablesDashboardHandler] } },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'service');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Variable' }));
|
||||
await screen.findByText(/circular dependency detected/);
|
||||
},
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
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 { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
findPreviewValue,
|
||||
openNewVariable,
|
||||
openVariableSelect,
|
||||
pickVariableType,
|
||||
typeVariableName,
|
||||
} from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The variable editor on a new variable, reached from Add variable in the
|
||||
* Variables tab: each type's own fields, the pickers they open, and the name
|
||||
* and attribute checks that keep Save disabled.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/New Variable',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** The editor as it opens, on the Dynamic type with no field picked. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** The dynamic variable's field picker, open on the attributes it can read. */
|
||||
export const FieldPickerOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-field-select');
|
||||
await screen.findByRole(
|
||||
'option',
|
||||
{ name: 'k8s.cluster.name' },
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The telemetry source the field is read from, open. */
|
||||
export const SignalPickerOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-signal-select');
|
||||
await screen.findByTitle('Traces');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A field picked: the name follows it until typed over, and the values it
|
||||
* resolves to fill the preview and the default value.
|
||||
*/
|
||||
export const DynamicFieldPicked: Story = {
|
||||
play: async (context) => {
|
||||
await FieldPickerOpen.play?.(context);
|
||||
await userEvent.click(
|
||||
screen.getByRole('option', { name: 'k8s.cluster.name' }),
|
||||
);
|
||||
await findPreviewValue('prod-us-east-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** A field another dynamic variable already reads, which Save refuses. */
|
||||
export const AttributeTaken: Story = {
|
||||
play: async (context) => {
|
||||
await FieldPickerOpen.play?.(context);
|
||||
await userEvent.click(
|
||||
screen.getByRole('option', { name: 'k8s.namespace.name' }),
|
||||
);
|
||||
await screen.findByText('A variable with this attribute key already exists');
|
||||
},
|
||||
};
|
||||
|
||||
/** The panels the new filter can be added to, open. */
|
||||
export const ApplyToPanelsOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await openVariableSelect('variable-apply-panels');
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name typed and cleared again. */
|
||||
export const NameRequired: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await userEvent.type(screen.getByTestId('variable-name'), 'x{Backspace}');
|
||||
await screen.findByText('Variable name is required');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name another variable on the dashboard has. */
|
||||
export const NameTaken: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('environment');
|
||||
await screen.findByText('Variable name already exists');
|
||||
},
|
||||
};
|
||||
|
||||
/** A name with a space in it, which a `$name` reference could not spell. */
|
||||
export const NameWithSpaces: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('build id');
|
||||
await screen.findByText('Variable name cannot contain whitespaces');
|
||||
},
|
||||
};
|
||||
|
||||
/** A Textbox variable: a free-text value with an optional default. */
|
||||
export const Textbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('region');
|
||||
await pickVariableType('textbox');
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('variable-text-input'),
|
||||
'us-east-1',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A Custom variable with its options typed in, previewed as they are parsed,
|
||||
* and multiple values on, which adds the ALL option toggle.
|
||||
*/
|
||||
export const Custom: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await typeVariableName('region');
|
||||
await pickVariableType('custom');
|
||||
await userEvent.type(
|
||||
await screen.findByTestId('variable-custom-input'),
|
||||
'us-east-1,eu-west-1,ap-south-1',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('variable-multi-switch'));
|
||||
await screen.findByText('Include an option for ALL values');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Custom variable's default value picker, open on the parsed options. */
|
||||
export const CustomDefaultOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Custom.play?.(context);
|
||||
await openVariableSelect('variable-default-select');
|
||||
await screen.findByRole('option', { name: 'eu-west-1' });
|
||||
},
|
||||
};
|
||||
|
||||
/** A Query variable before its query is written: Test Run waits for one. */
|
||||
export const Query: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openNewVariable(canvasElement);
|
||||
await pickVariableType('query');
|
||||
await screen.findByText('Test Run Query');
|
||||
},
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
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 { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import { openSettings } from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The dashboard's settings drawer on its Overview and Publish tabs: the name,
|
||||
* icon, description and tags, the cross-panel sync mode, and the public link.
|
||||
* The Publish tab only exists on cloud and enterprise licenses.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Settings',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** The drawer as Configure opens it, on the Overview tab. */
|
||||
export const Overview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await screen.findByText('Sync Mode');
|
||||
},
|
||||
};
|
||||
|
||||
/** Two fields edited, so the footer counts them and offers to save or discard. */
|
||||
export const OverviewUnsavedChanges: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
|
||||
await userEvent.type(
|
||||
screen.getByTestId('dashboard-desc'),
|
||||
' Owned by the platform team.',
|
||||
);
|
||||
await screen.findByText('2 unsaved changes');
|
||||
},
|
||||
};
|
||||
|
||||
/** The icon picker beside the name, open on the system icons. */
|
||||
export const OverviewIconPicker: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const panel = await openSettings(canvasElement);
|
||||
|
||||
await userEvent.click(within(panel).getAllByRole('combobox')[0]);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync set to Tooltip, which adds the choice between every series and only the
|
||||
* ones that share the group-by.
|
||||
*/
|
||||
export const OverviewTooltipSync: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement);
|
||||
await userEvent.click(await screen.findByRole('radio', { name: 'Tooltip' }));
|
||||
await screen.findByText('Synced Tooltip Series');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Overview tab's own tooltip: Cross-Panel Sync explains what syncing does
|
||||
* and links out to the docs.
|
||||
*/
|
||||
export const TooltipsInOverview: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: Overview.play,
|
||||
};
|
||||
|
||||
/** The Publish tab of a dashboard with a public link. */
|
||||
export const Publish: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await screen.findByText('Default time range');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab with its default time range select open. */
|
||||
export const PublishTimeRangeOpen: Story = {
|
||||
play: async (context) => {
|
||||
await Publish.play?.(context);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('tabpanel')).getByRole('combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab of a dashboard nobody published: no link, one Publish button. */
|
||||
export const PublishPrivate: Story = {
|
||||
args: { published: false },
|
||||
// The 404 an unpublished dashboard answers with is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Publish');
|
||||
await screen.findByText('This dashboard is private');
|
||||
},
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
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 { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { dashboardMocks } from '../DashboardPage.stories.mocks';
|
||||
import {
|
||||
clickRowAction,
|
||||
openSettings,
|
||||
openVariableEditor,
|
||||
typeVariableName,
|
||||
variableRow,
|
||||
} from './settingsPlay';
|
||||
|
||||
import DashboardPage from '../../DashboardPage';
|
||||
|
||||
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
|
||||
|
||||
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* The Variables tab of dashboard settings: the variables in the order the bar
|
||||
* shows them, and what deleting, renaming or applying one does to the panels
|
||||
* that read it. Every panel filters on `$environment`, and `$service` reads it
|
||||
* too; nothing reads `$namespace` or `$owner`.
|
||||
*
|
||||
* Route: `/dashboard/:dashboardId`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Dashboards/Detail/Variables',
|
||||
tags: ['authz', 'play'],
|
||||
component: DashboardPage as ComponentType<DashboardArgs>,
|
||||
render: (): JSX.Element => (
|
||||
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<DashboardArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DashboardArgs>;
|
||||
|
||||
/** One variable of each type, listed by name and description. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await variableRow('owner');
|
||||
},
|
||||
};
|
||||
|
||||
/** A dashboard without variables, where the tab is its add-variable prompt. */
|
||||
export const Empty: Story = {
|
||||
args: { variables: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await screen.findByText('No variables yet');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dynamic variable's Apply to all says whether it is already a filter on
|
||||
* every panel. The row keeps its actions invisible until it is hovered, which
|
||||
* the story does first.
|
||||
*/
|
||||
export const TooltipsInVariables: Story = {
|
||||
args: { tooltipsOpen: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
|
||||
// The tooltip trigger's Slot merge drops the button's own test id.
|
||||
await userEvent.hover(
|
||||
await screen.findByRole(
|
||||
'button',
|
||||
{ name: 'Apply to all' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText(
|
||||
'Add this variable as a filter to every panel',
|
||||
undefined,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Deleting a variable nothing reads, which the row confirms inline. */
|
||||
export const DeleteConfirm: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('owner', 'Delete variable');
|
||||
await screen.findByText('Delete?');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Deleting `$environment`, which the panels and `$service` read, so each usage
|
||||
* is listed for review before anything is removed.
|
||||
*/
|
||||
export const DeleteReferenced: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('environment', 'Delete variable');
|
||||
await screen.findByText('Delete $environment');
|
||||
},
|
||||
};
|
||||
|
||||
/** Apply to all on `$namespace`: the filter it would add to every panel query. */
|
||||
export const ApplyToAll: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction('namespace', 'Apply to all');
|
||||
await screen.findByText('Apply $namespace to panels');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Renaming `$environment` to `$env`: saving the editor holds the rename until
|
||||
* the rewritten queries are reviewed.
|
||||
*/
|
||||
export const RenameReferenced: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openVariableEditor(canvasElement, 'environment');
|
||||
await typeVariableName('env');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save Variable' }));
|
||||
await screen.findByText('Rename $environment');
|
||||
},
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
// The dashboard document loads before the toolbar renders.
|
||||
const PAGE_LOAD = { timeout: 10000 };
|
||||
|
||||
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
|
||||
|
||||
export type VariableType = 'dynamic' | 'textbox' | 'custom' | 'query';
|
||||
|
||||
type RowAction = 'Edit variable' | 'Delete variable' | 'Apply to all';
|
||||
|
||||
/** Opens the settings drawer from the toolbar and returns the tab's panel. */
|
||||
export const openSettings = async (
|
||||
canvasElement: HTMLElement,
|
||||
tab: SettingsTab = 'Overview',
|
||||
): Promise<HTMLElement> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Configure' },
|
||||
PAGE_LOAD,
|
||||
),
|
||||
);
|
||||
|
||||
if (tab !== 'Overview') {
|
||||
await userEvent.click(await screen.findByRole('tab', { name: tab }));
|
||||
}
|
||||
|
||||
return screen.findByRole('tabpanel');
|
||||
};
|
||||
|
||||
export const variableRow = (name: string): Promise<HTMLElement> =>
|
||||
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);
|
||||
|
||||
/** Row actions render once the edit permission resolves. */
|
||||
export const clickRowAction = async (
|
||||
name: string,
|
||||
action: RowAction,
|
||||
): Promise<void> => {
|
||||
const row = await variableRow(name);
|
||||
|
||||
await userEvent.click(
|
||||
await within(row).findByRole('button', { name: action }, PAGE_LOAD),
|
||||
);
|
||||
};
|
||||
|
||||
/** A new variable's editor, which opens on the Dynamic type. */
|
||||
export const openNewVariable = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const panel = await openSettings(canvasElement, 'Variables');
|
||||
const add = await within(panel).findByRole(
|
||||
'button',
|
||||
{ name: 'Add variable' },
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
// Disabled until its permission check resolves: through `disabled` or
|
||||
// `aria-disabled`, depending on the Button.
|
||||
await waitFor(() => expect(add).toBeEnabled(), PAGE_LOAD);
|
||||
await waitFor(
|
||||
() => expect(add).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
PAGE_LOAD,
|
||||
);
|
||||
await userEvent.click(add);
|
||||
await screen.findByText('Variable Type');
|
||||
};
|
||||
|
||||
export const openVariableEditor = async (
|
||||
canvasElement: HTMLElement,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
await openSettings(canvasElement, 'Variables');
|
||||
await clickRowAction(name, 'Edit variable');
|
||||
await screen.findByText('Variable Type');
|
||||
};
|
||||
|
||||
/**
|
||||
* A value in the editor's preview. The toolbar's selector for the same variable
|
||||
* resolves to its first value too, whenever its own query returns.
|
||||
*/
|
||||
export const findPreviewValue = async (value: string): Promise<HTMLElement> => {
|
||||
const label = await screen.findByText('Preview of Values');
|
||||
|
||||
return within(label.parentElement as HTMLElement).findByText(
|
||||
value,
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
};
|
||||
|
||||
export const pickVariableType = async (type: VariableType): Promise<void> => {
|
||||
await userEvent.click(await screen.findByTestId(`variable-type-${type}`));
|
||||
};
|
||||
|
||||
export const typeVariableName = async (name: string): Promise<void> => {
|
||||
const input = await screen.findByTestId('variable-name');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, name);
|
||||
};
|
||||
|
||||
/**
|
||||
* The editor's pickers are antd Selects: the element carrying the test id does
|
||||
* nothing on click, the combobox inside it is what opens the list.
|
||||
*/
|
||||
export const openVariableSelect = async (testId: string): Promise<void> => {
|
||||
const select = await screen.findByTestId(testId);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
@@ -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' }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user